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 origin (design D10): they edit/delete local skills and create/update/remove
local overrides for cloud skills, never writing to the synced store. local overrides for cloud skills, never writing to the synced store.
""" """
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
@@ -140,9 +141,7 @@ def skill_tool_handlers(
except ValueError as exc: except ValueError as exc:
return _error_response(SkillAuthoringError(str(exc))) return _error_response(SkillAuthoringError(str(exc)))
if view.is_local_skill(skill_id): if view.is_local_skill(skill_id):
stored = local.update_local( stored = local.update_local(_with_id(skill, skill_id))
_with_id(skill, skill_id)
)
return { return {
"ok": True, "ok": True,
"skill": _skill_to_full_dict(stored), "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 []) for step in (payload.get("steps") or [])
] ]
parameters = { 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) 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 skill at read time, wins against sync updates, and is reported with
``origin = "cloud"`` and ``locally_overridden = true`` while it shadows. ``origin = "cloud"`` and ``locally_overridden = true`` while it shadows.
""" """
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal from typing import Literal
from skills_learning.models import ( from skills_learning.models import (
FlowTemplateSkill, FlowTemplateSkill,
KnowledgeSkill, KnowledgeSkill,
Skill,
SkillMetadata, SkillMetadata,
) )
from storage.local_skills import LocalSkillStore from storage.local_skills import LocalSkillStore
@@ -72,9 +71,7 @@ class SkillCatalogView:
if meta.id in override_ids: if meta.id in override_ids:
override = self._local.get_override(meta.id) override = self._local.get_override(meta.id)
if override is not None: if override is not None:
summaries.append( summaries.append(SkillSummary(override.metadata, "cloud", True))
SkillSummary(override.metadata, "cloud", True)
)
continue continue
summaries.append(SkillSummary(meta, "cloud", False)) summaries.append(SkillSummary(meta, "cloud", False))
@@ -96,9 +93,7 @@ class SkillCatalogView:
if meta.id in override_ids: if meta.id in override_ids:
override = self._local.get_override(meta.id) override = self._local.get_override(meta.id)
if override is not None: if override is not None:
summaries.append( summaries.append(SkillSummary(override.metadata, "cloud", True))
SkillSummary(override.metadata, "cloud", True)
)
continue continue
summaries.append(SkillSummary(meta, "cloud", False)) summaries.append(SkillSummary(meta, "cloud", False))
@@ -148,9 +143,7 @@ class SkillCatalogView:
return self._local.has_override(cloud_skill_id) return self._local.has_override(cloud_skill_id)
def _tools_valid( def _tools_valid(skill: FlowTemplateSkill, registered_tools: set[str]) -> bool:
skill: FlowTemplateSkill, registered_tools: set[str]
) -> bool:
return all(step.tool_name in registered_tools for step in skill.steps) 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. Push (webhook) is optional; the baseline pull loop is correct standalone.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -185,9 +186,7 @@ class CloudApiSkillClient:
response.raise_for_status() response.raise_for_status()
return _parse_cloud_sync_payload(response.json()) return _parse_cloud_sync_payload(response.json())
def report_inventory( def report_inventory(self, host_id: str, inventory: list[dict[str, Any]]) -> None:
self, host_id: str, inventory: list[dict[str, Any]]
) -> None:
"""Best-effort local-skill inventory report to the Cloud (design D7).""" """Best-effort local-skill inventory report to the Cloud (design D7)."""
response = self._ensure_client().post( response = self._ensure_client().post(
f"{self.base_url}/internal/v1/hosts/{host_id}/skills/inventory", f"{self.base_url}/internal/v1/hosts/{host_id}/skills/inventory",
@@ -312,9 +311,7 @@ class SkillSyncRunner:
subscription_id, since_version=since_version subscription_id, since_version=since_version
) )
except Exception as exc: except Exception as exc:
log.warning( log.warning("skill sync fetch failed for %s: %s", subscription_id, exc)
"skill sync fetch failed for %s: %s", subscription_id, exc
)
self.store._set_subscription_state( self.store._set_subscription_state(
subscription_id, subscription_id,
last_error=f"{type(exc).__name__}: {exc}", 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 (design D7). Lives in ``host_agent`` (not ``runtime``) for the same boundary
reasons as :mod:`host_agent.cloud_planner_client`. reasons as :mod:`host_agent.cloud_planner_client`.
""" """
from __future__ import annotations from __future__ import annotations
import logging 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 transport requires valid provider credentials on that Host; preserve usage and
policy rows rather than deleting accounting history. 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 ## Operational Limitations
Run exactly one scheduler-enabled Cloud API process. SQLite supports only the Run exactly one scheduler-enabled Cloud API process. SQLite supports only the
@@ -52,5 +52,5 @@
## 9. Documentation and validation ## 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. - [x] 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.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 heartbeat/planner-decision) endpoints an agent uses to pull incremental
skill deltas and report its local-skill inventory. skill deltas and report its local-skill inventory.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
@@ -16,7 +17,12 @@ from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, Request, status 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.observability import current_correlation_id
from cloud.skills import ( from cloud.skills import (
CloudSkillConflictError, CloudSkillConflictError,
@@ -86,7 +92,9 @@ def create_skill_management_router(
authorize(request) authorize(request)
skill = service.get_skill(skill_id) skill = service.get_skill(skill_id)
if skill is None: 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) return _skill_response(skill)
@router.post( @router.post(
@@ -112,9 +120,13 @@ def create_skill_management_router(
now=utc_now(), now=utc_now(),
) )
except CloudSkillValidationError as exc: 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: 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") _audit(repository, principal, skill.id, "cloud_skill_create")
return _skill_response(skill) return _skill_response(skill)
@@ -139,9 +151,13 @@ def create_skill_management_router(
now=utc_now(), now=utc_now(),
) )
except CloudSkillValidationError as exc: 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: 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") _audit(repository, principal, skill.id, "cloud_skill_update")
return _skill_response(skill) return _skill_response(skill)
@@ -152,14 +168,18 @@ def create_skill_management_router(
try: try:
service.delete_skill(skill_id) service.delete_skill(skill_id)
except KeyError as exc: 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") _audit(repository, principal, skill_id, "cloud_skill_delete")
@router.get( @router.get(
"/skills/{skill_id}/entitlements", "/skills/{skill_id}/entitlements",
response_model=CloudSkillEntitlementListResponse, response_model=CloudSkillEntitlementListResponse,
) )
def list_entitlements(skill_id: str, request: Request) -> CloudSkillEntitlementListResponse: def list_entitlements(
skill_id: str, request: Request
) -> CloudSkillEntitlementListResponse:
authorize(request) authorize(request)
return CloudSkillEntitlementListResponse( return CloudSkillEntitlementListResponse(
skill_id=skill_id, skill_id=skill_id,
@@ -176,7 +196,9 @@ def create_skill_management_router(
try: try:
service.grant_entitlement(skill_id, host_id, now=utc_now()) service.grant_entitlement(skill_id, host_id, now=utc_now())
except CloudSkillValidationError as exc: 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") _audit(repository, principal, skill_id, "cloud_skill_entitlement_grant")
@router.delete( @router.delete(
@@ -193,7 +215,9 @@ def create_skill_management_router(
"/hosts/{host_id}/skill-inventory", "/hosts/{host_id}/skill-inventory",
response_model=HostSkillInventoryResponse, 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) authorize(request)
entry = service.get_host_inventory(host_id) entry = service.get_host_inventory(host_id)
if entry is None: if entry is None:
@@ -231,7 +255,9 @@ def create_skill_host_router(
try: try:
principal.require_host(host_id) principal.require_host(host_id)
except HostAuthorizationError as exc: 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) @router.get("/hosts/{host_id}/skills/sync", response_model=CloudSkillSyncResponse)
def sync_skills( 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 monotonic ``entitlement_version`` and is recorded in a per-host changelog so
:meth:`fetch_host_delta` can serve a correct incremental delta. :meth:`fetch_host_delta` can serve a correct incremental delta.
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
from uuid import uuid4 from uuid import uuid4
from core.models import utc_now
SkillKind = Literal["knowledge", "flow_template"] SkillKind = Literal["knowledge", "flow_template"]
SUPPORTED_SKILL_KINDS = frozenset({"knowledge", "flow_template"}) SUPPORTED_SKILL_KINDS = frozenset({"knowledge", "flow_template"})
@@ -86,9 +86,7 @@ def validate_skill_input(
) -> tuple[str, str, SkillKind]: ) -> tuple[str, str, SkillKind]:
display = " ".join(name.split()) display = " ".join(name.split())
if not display or len(display) > 200: if not display or len(display) > 200:
raise CloudSkillValidationError( raise CloudSkillValidationError("Skill name must contain 1 to 200 characters")
"Skill name must contain 1 to 200 characters"
)
if kind not in SUPPORTED_SKILL_KINDS: if kind not in SUPPORTED_SKILL_KINDS:
raise CloudSkillValidationError("Skill kind must be knowledge or flow_template") raise CloudSkillValidationError("Skill kind must be knowledge or flow_template")
if len(description) > 2000: 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 to ``tasks/skills.sqlite3`` and :mod:`storage.skill_catalog` never
writes here. writes here.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
@@ -75,9 +76,7 @@ class LocalSkillStore:
).fetchall() ).fetchall()
return [_row_to_metadata(row) for row in rows] return [_row_to_metadata(row) for row in rows]
def get_local( def get_local(self, skill_id: str) -> KnowledgeSkill | FlowTemplateSkill | None:
self, skill_id: str
) -> KnowledgeSkill | FlowTemplateSkill | None:
with self._connect() as conn: with self._connect() as conn:
row = conn.execute( row = conn.execute(
"SELECT * FROM local_skills WHERE id = ?", "SELECT * FROM local_skills WHERE id = ?",
@@ -109,9 +108,7 @@ class LocalSkillStore:
def delete_local(self, skill_id: str) -> bool: def delete_local(self, skill_id: str) -> bool:
with self._connect() as conn: with self._connect() as conn:
cur = conn.execute( cur = conn.execute("DELETE FROM local_skills WHERE id = ?", (skill_id,))
"DELETE FROM local_skills WHERE id = ?", (skill_id,)
)
return cur.rowcount > 0 return cur.rowcount > 0
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -138,9 +135,7 @@ class LocalSkillStore:
def list_override_cloud_ids(self) -> set[str]: def list_override_cloud_ids(self) -> set[str]:
with self._connect() as conn: with self._connect() as conn:
rows = conn.execute( rows = conn.execute("SELECT cloud_skill_id FROM overrides").fetchall()
"SELECT cloud_skill_id FROM overrides"
).fetchall()
return {row["cloud_skill_id"] for row in rows} return {row["cloud_skill_id"] for row in rows}
def list_overrides(self) -> list[KnowledgeSkill | FlowTemplateSkill]: def list_overrides(self) -> list[KnowledgeSkill | FlowTemplateSkill]:
@@ -321,8 +316,7 @@ class LocalSkillStore:
""" """
) )
conn.execute( conn.execute(
"CREATE INDEX IF NOT EXISTS idx_local_skills_name " "CREATE INDEX IF NOT EXISTS idx_local_skills_name ON local_skills(name)"
"ON local_skills(name)"
) )
def _connect(self) -> sqlite3.Connection: def _connect(self) -> sqlite3.Connection:
@@ -378,9 +372,7 @@ def _with_metadata(
return FlowTemplateSkill( return FlowTemplateSkill(
metadata=new_meta, metadata=new_meta,
steps=list(skill.steps), steps=list(skill.steps),
parameters={ parameters={name: dict(schema) for name, schema in skill.parameters.items()},
name: dict(schema) for name, schema in skill.parameters.items()
},
) )