From f8054cb58c9117488e1e2c81b43617448d3abb33 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Wed, 15 Jul 2026 08:10:20 +0800 Subject: [PATCH] chore(skills): docs + ruff format for skill-management-console 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 --- api/skill_catalog_mcp.py | 8 ++-- api/skill_catalog_view.py | 15 ++---- api/skill_sync.py | 9 ++-- .../host_agent/skill_sync.py | 1 + docs/CLOUD_DEPLOYMENT.md | 23 +++++++++ .../changes/skill-management-console/tasks.md | 4 +- .../cloud-platform/cloud/sdk/skill_api.py | 48 ++++++++++++++----- packages/cloud-platform/cloud/skills.py | 8 ++-- storage/local_skills.py | 20 +++----- 9 files changed, 83 insertions(+), 53 deletions(-) diff --git a/api/skill_catalog_mcp.py b/api/skill_catalog_mcp.py index 42fac01..b64fe31 100644 --- a/api/skill_catalog_mcp.py +++ b/api/skill_catalog_mcp.py @@ -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) diff --git a/api/skill_catalog_view.py b/api/skill_catalog_view.py index db569bf..680ab1a 100644 --- a/api/skill_catalog_view.py +++ b/api/skill_catalog_view.py @@ -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) diff --git a/api/skill_sync.py b/api/skill_sync.py index 7caca87..9af22e2 100644 --- a/api/skill_sync.py +++ b/api/skill_sync.py @@ -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}", diff --git a/apps/device-host-agent/host_agent/skill_sync.py b/apps/device-host-agent/host_agent/skill_sync.py index eff573d..e0df2bf 100644 --- a/apps/device-host-agent/host_agent/skill_sync.py +++ b/apps/device-host-agent/host_agent/skill_sync.py @@ -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 diff --git a/docs/CLOUD_DEPLOYMENT.md b/docs/CLOUD_DEPLOYMENT.md index 2bead8c..acc379c 100644 --- a/docs/CLOUD_DEPLOYMENT.md +++ b/docs/CLOUD_DEPLOYMENT.md @@ -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 diff --git a/openspec/changes/skill-management-console/tasks.md b/openspec/changes/skill-management-console/tasks.md index 1292a29..57c05bb 100644 --- a/openspec/changes/skill-management-console/tasks.md +++ b/openspec/changes/skill-management-console/tasks.md @@ -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. diff --git a/packages/cloud-platform/cloud/sdk/skill_api.py b/packages/cloud-platform/cloud/sdk/skill_api.py index 37d7059..c9c2197 100644 --- a/packages/cloud-platform/cloud/sdk/skill_api.py +++ b/packages/cloud-platform/cloud/sdk/skill_api.py @@ -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( diff --git a/packages/cloud-platform/cloud/skills.py b/packages/cloud-platform/cloud/skills.py index e557390..7a662ed 100644 --- a/packages/cloud-platform/cloud/skills.py +++ b/packages/cloud-platform/cloud/skills.py @@ -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: diff --git a/storage/local_skills.py b/storage/local_skills.py index fa81c30..ef45b6e 100644 --- a/storage/local_skills.py +++ b/storage/local_skills.py @@ -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()}, )