chore(skills): docs + ruff format for skill-management-console
Tests / Test passed: 855

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>
This commit is contained in:
2026-07-15 08:10:20 +08:00
co-authored by Claude Opus 4.6
parent fd0ea3a066
commit f8054cb58c
9 changed files with 83 additions and 53 deletions
+4 -4
View File
@@ -12,6 +12,7 @@ Authoring tools (``create_skill``/``update_skill``/``delete_skill``) dispatch by
origin (design D10): they edit/delete local skills and create/update/remove
local overrides for cloud skills, never writing to the synced store.
"""
from __future__ import annotations
from collections.abc import Callable
@@ -140,9 +141,7 @@ def skill_tool_handlers(
except ValueError as exc:
return _error_response(SkillAuthoringError(str(exc)))
if view.is_local_skill(skill_id):
stored = local.update_local(
_with_id(skill, skill_id)
)
stored = local.update_local(_with_id(skill, skill_id))
return {
"ok": True,
"skill": _skill_to_full_dict(stored),
@@ -308,7 +307,8 @@ def _build_skill(payload: dict[str, Any]) -> KnowledgeSkill | FlowTemplateSkill:
for step in (payload.get("steps") or [])
]
parameters = {
str(name): dict(schema) for name, schema in (payload.get("parameters") or {}).items()
str(name): dict(schema)
for name, schema in (payload.get("parameters") or {}).items()
}
return FlowTemplateSkill(metadata=meta, steps=steps, parameters=parameters)
+4 -11
View File
@@ -9,16 +9,15 @@ Override semantics (design D8/D9/D11): a local override shadows the cloud
skill at read time, wins against sync updates, and is reported with
``origin = "cloud"`` and ``locally_overridden = true`` while it shadows.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Literal
from skills_learning.models import (
FlowTemplateSkill,
KnowledgeSkill,
Skill,
SkillMetadata,
)
from storage.local_skills import LocalSkillStore
@@ -72,9 +71,7 @@ class SkillCatalogView:
if meta.id in override_ids:
override = self._local.get_override(meta.id)
if override is not None:
summaries.append(
SkillSummary(override.metadata, "cloud", True)
)
summaries.append(SkillSummary(override.metadata, "cloud", True))
continue
summaries.append(SkillSummary(meta, "cloud", False))
@@ -96,9 +93,7 @@ class SkillCatalogView:
if meta.id in override_ids:
override = self._local.get_override(meta.id)
if override is not None:
summaries.append(
SkillSummary(override.metadata, "cloud", True)
)
summaries.append(SkillSummary(override.metadata, "cloud", True))
continue
summaries.append(SkillSummary(meta, "cloud", False))
@@ -148,9 +143,7 @@ class SkillCatalogView:
return self._local.has_override(cloud_skill_id)
def _tools_valid(
skill: FlowTemplateSkill, registered_tools: set[str]
) -> bool:
def _tools_valid(skill: FlowTemplateSkill, registered_tools: set[str]) -> bool:
return all(step.tool_name in registered_tools for step in skill.steps)
+3 -6
View File
@@ -7,6 +7,7 @@ truth" contract is enforced by that import boundary.
Push (webhook) is optional; the baseline pull loop is correct standalone.
"""
from __future__ import annotations
import logging
@@ -185,9 +186,7 @@ class CloudApiSkillClient:
response.raise_for_status()
return _parse_cloud_sync_payload(response.json())
def report_inventory(
self, host_id: str, inventory: list[dict[str, Any]]
) -> None:
def report_inventory(self, host_id: str, inventory: list[dict[str, Any]]) -> None:
"""Best-effort local-skill inventory report to the Cloud (design D7)."""
response = self._ensure_client().post(
f"{self.base_url}/internal/v1/hosts/{host_id}/skills/inventory",
@@ -312,9 +311,7 @@ class SkillSyncRunner:
subscription_id, since_version=since_version
)
except Exception as exc:
log.warning(
"skill sync fetch failed for %s: %s", subscription_id, exc
)
log.warning("skill sync fetch failed for %s: %s", subscription_id, exc)
self.store._set_subscription_state(
subscription_id,
last_error=f"{type(exc).__name__}: {exc}",
@@ -8,6 +8,7 @@ best-effort inventory of the agent's local skills is reported to the Cloud
(design D7). Lives in ``host_agent`` (not ``runtime``) for the same boundary
reasons as :mod:`host_agent.cloud_planner_client`.
"""
from __future__ import annotations
import logging
+23
View File
@@ -478,6 +478,29 @@ legacy Provider environment configuration, while a rollback to `direct`
transport requires valid provider credentials on that Host; preserve usage and
policy rows rather than deleting accounting history.
## Skill Management
Cloud-origin Skills are administrator-managed through the Cloud Console's
**Skills** view (requires the `skills:admin` scope, which administrators
hold via the `*` scope). Create/edit/delete skills (knowledge or flow-template
kinds), and grant or revoke per-host entitlement — an agent only ever sees the
cloud skills entitled to its own host.
Agents pull their entitled cloud skills incrementally from the Cloud API
(`GET /internal/v1/hosts/{host_id}/skills/sync`) on a configurable cadence
(`HOST_AGENT_SKILL_SYNC_INTERVAL_SECONDS`, default 300s) and cache them in a
local SQLite file (`tasks/skills.sqlite3`). Agents may also author their own
**local skills** (persisted in a separate `tasks/local_skills.sqlite3`) and
**override** a cloud skill locally via the MCP authoring tools; an override
shadows the cloud skill until removed, and forks into a standalone local skill
if the cloud entitlement is revoked. Agents report a best-effort read-only
inventory of their local skills to the Cloud so the Console can display them
per host.
The cloud skill store, entitlement mapping, per-host sync versioning, and
inventory readback live in the Cloud platform database (migration
`0010_skill_management`).
## Operational Limitations
Run exactly one scheduler-enabled Cloud API process. SQLite supports only the
@@ -52,5 +52,5 @@
## 9. Documentation and validation
- [ ] 9.1 Update Cloud deployment docs: cloud-skill management, per-host entitlement, sync endpoint, inventory report, and the `skills:admin` scope.
- [ ] 9.2 Run backend, agent, and Console tests; ruff check/format; compileall; and `openspec validate skill-management-console --strict`; resolve failures.
- [x] 9.1 Update Cloud deployment docs: cloud-skill management, per-host entitlement, sync endpoint, inventory report, and the `skills:admin` scope.
- [x] 9.2 Run backend, agent, and Console tests; ruff check/format; compileall; and `openspec validate skill-management-console --strict`; resolve failures.
+37 -11
View File
@@ -8,6 +8,7 @@ Two routers:
heartbeat/planner-decision) endpoints an agent uses to pull incremental
skill deltas and report its local-skill inventory.
"""
from __future__ import annotations
import json
@@ -16,7 +17,12 @@ from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, Request, status
from cloud.auth import AuthProvider, HostAuthorizationError, Principal, SKILLS_ADMIN_SCOPE
from cloud.auth import (
AuthProvider,
HostAuthorizationError,
Principal,
SKILLS_ADMIN_SCOPE,
)
from cloud.observability import current_correlation_id
from cloud.skills import (
CloudSkillConflictError,
@@ -86,7 +92,9 @@ def create_skill_management_router(
authorize(request)
skill = service.get_skill(skill_id)
if skill is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Skill not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Skill not found"
)
return _skill_response(skill)
@router.post(
@@ -112,9 +120,13 @@ def create_skill_management_router(
now=utc_now(),
)
except CloudSkillValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
except CloudSkillConflictError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
_audit(repository, principal, skill.id, "cloud_skill_create")
return _skill_response(skill)
@@ -139,9 +151,13 @@ def create_skill_management_router(
now=utc_now(),
)
except CloudSkillValidationError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Skill not found") from exc
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Skill not found"
) from exc
except CloudSkillConflictError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
_audit(repository, principal, skill.id, "cloud_skill_update")
return _skill_response(skill)
@@ -152,14 +168,18 @@ def create_skill_management_router(
try:
service.delete_skill(skill_id)
except KeyError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Skill not found") from exc
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Skill not found"
) from exc
_audit(repository, principal, skill_id, "cloud_skill_delete")
@router.get(
"/skills/{skill_id}/entitlements",
response_model=CloudSkillEntitlementListResponse,
)
def list_entitlements(skill_id: str, request: Request) -> CloudSkillEntitlementListResponse:
def list_entitlements(
skill_id: str, request: Request
) -> CloudSkillEntitlementListResponse:
authorize(request)
return CloudSkillEntitlementListResponse(
skill_id=skill_id,
@@ -176,7 +196,9 @@ def create_skill_management_router(
try:
service.grant_entitlement(skill_id, host_id, now=utc_now())
except CloudSkillValidationError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
_audit(repository, principal, skill_id, "cloud_skill_entitlement_grant")
@router.delete(
@@ -193,7 +215,9 @@ def create_skill_management_router(
"/hosts/{host_id}/skill-inventory",
response_model=HostSkillInventoryResponse,
)
def get_host_inventory(host_id: str, request: Request) -> HostSkillInventoryResponse:
def get_host_inventory(
host_id: str, request: Request
) -> HostSkillInventoryResponse:
authorize(request)
entry = service.get_host_inventory(host_id)
if entry is None:
@@ -231,7 +255,9 @@ def create_skill_host_router(
try:
principal.require_host(host_id)
except HostAuthorizationError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)
) from exc
@router.get("/hosts/{host_id}/skills/sync", response_model=CloudSkillSyncResponse)
def sync_skills(
+3 -5
View File
@@ -7,14 +7,14 @@ 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 dataclasses import dataclass
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"})
@@ -86,9 +86,7 @@ def validate_skill_input(
) -> 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"
)
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:
+6 -14
View File
@@ -8,6 +8,7 @@ synced store's read-only-except-sync contract is preserved: this module never
writes to ``tasks/skills.sqlite3`` and :mod:`storage.skill_catalog` never
writes here.
"""
from __future__ import annotations
import json
@@ -75,9 +76,7 @@ class LocalSkillStore:
).fetchall()
return [_row_to_metadata(row) for row in rows]
def get_local(
self, skill_id: str
) -> KnowledgeSkill | FlowTemplateSkill | None:
def get_local(self, skill_id: str) -> KnowledgeSkill | FlowTemplateSkill | None:
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM local_skills WHERE id = ?",
@@ -109,9 +108,7 @@ class LocalSkillStore:
def delete_local(self, skill_id: str) -> bool:
with self._connect() as conn:
cur = conn.execute(
"DELETE FROM local_skills WHERE id = ?", (skill_id,)
)
cur = conn.execute("DELETE FROM local_skills WHERE id = ?", (skill_id,))
return cur.rowcount > 0
# ------------------------------------------------------------------
@@ -138,9 +135,7 @@ class LocalSkillStore:
def list_override_cloud_ids(self) -> set[str]:
with self._connect() as conn:
rows = conn.execute(
"SELECT cloud_skill_id FROM overrides"
).fetchall()
rows = conn.execute("SELECT cloud_skill_id FROM overrides").fetchall()
return {row["cloud_skill_id"] for row in rows}
def list_overrides(self) -> list[KnowledgeSkill | FlowTemplateSkill]:
@@ -321,8 +316,7 @@ class LocalSkillStore:
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_local_skills_name "
"ON local_skills(name)"
"CREATE INDEX IF NOT EXISTS idx_local_skills_name ON local_skills(name)"
)
def _connect(self) -> sqlite3.Connection:
@@ -378,9 +372,7 @@ def _with_metadata(
return FlowTemplateSkill(
metadata=new_meta,
steps=list(skill.steps),
parameters={
name: dict(schema) for name, schema in skill.parameters.items()
},
parameters={name: dict(schema) for name, schema in skill.parameters.items()},
)