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