Adds CloudApiSkillClient (Cloud API per-host sync endpoint + inventory report), forwards since_version for incremental sync (full-replace on first/stale), and forks a local override into a standalone local skill when its cloud skill is revoked (design D9). Skill-side tests green (87 passed). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
420 lines
14 KiB
Python
420 lines
14 KiB
Python
"""Local storage for skills synced from the external Subscription Platform.
|
|
|
|
Storage layer per CONSTITUTION.md: zero HTTP/MCP/LLM dependencies. Public read
|
|
API is list_skills/search_skills/get_skill only. Write methods are prefixed
|
|
with ``_`` and are callable only from :mod:`api.skill_sync` — this enforces
|
|
the "Subscription Platform is sole source of truth" contract from the
|
|
``skill-authoring`` capability boundary (no local authoring into synced rows).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from core.models import utc_now
|
|
from skills_learning.models import (
|
|
FlowStep,
|
|
FlowTemplateSkill,
|
|
KnowledgeSkill,
|
|
Skill,
|
|
SkillMetadata,
|
|
)
|
|
|
|
|
|
class SkillCatalogStore:
|
|
"""SQLite-backed local cache of externally-synced skills.
|
|
|
|
The database file defaults to ``tasks/skills.sqlite3``, physically separate
|
|
from both ``tasks/tasks.sqlite3`` (task metadata) and the in-memory
|
|
``skills_learning.SkillStore`` (local synthesis), honoring the
|
|
``skill-authoring`` capability's "no cross-writes" contract.
|
|
"""
|
|
|
|
def __init__(self, db_path: str | Path = "tasks/skills.sqlite3") -> None:
|
|
self.db_path = Path(db_path)
|
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._ensure_schema()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Public read API
|
|
# ------------------------------------------------------------------
|
|
|
|
def list_skills(
|
|
self,
|
|
active_subscriptions: set[str],
|
|
) -> list[SkillMetadata]:
|
|
effective = self._effective_active(active_subscriptions)
|
|
if not effective:
|
|
return []
|
|
with self._connect() as conn:
|
|
rows = conn.execute(
|
|
f"""
|
|
SELECT id, name, description, kind, tags_json, version,
|
|
source, created_at, updated_at
|
|
FROM skills
|
|
WHERE subscription_id IN ({_placeholders(effective)})
|
|
""",
|
|
tuple(effective),
|
|
).fetchall()
|
|
metas = [_row_to_metadata(row) for row in rows]
|
|
metas.sort(key=lambda m: m.name)
|
|
return metas
|
|
|
|
def search_skills(
|
|
self,
|
|
query: str,
|
|
active_subscriptions: set[str],
|
|
) -> list[SkillMetadata]:
|
|
effective = self._effective_active(active_subscriptions)
|
|
if not effective:
|
|
return []
|
|
like = f"%{query}%"
|
|
with self._connect() as conn:
|
|
rows = conn.execute(
|
|
f"""
|
|
SELECT id, name, description, kind, tags_json, version,
|
|
source, created_at, updated_at
|
|
FROM skills
|
|
WHERE subscription_id IN ({_placeholders(effective)})
|
|
AND (name LIKE ? OR description LIKE ? OR tags_json LIKE ?)
|
|
""",
|
|
(*effective, like, like, like),
|
|
).fetchall()
|
|
metas = [_row_to_metadata(row) for row in rows]
|
|
_rank_by_relevance(metas, query)
|
|
return metas
|
|
|
|
def get_skill(
|
|
self,
|
|
skill_id: str,
|
|
active_subscriptions: set[str],
|
|
*,
|
|
registered_tools: set[str] | None = None,
|
|
) -> KnowledgeSkill | FlowTemplateSkill | None:
|
|
"""Return the skill if visible, else ``None``.
|
|
|
|
Returns ``None`` for both unknown ids and known-but-not-visible ids
|
|
(no existence leak). If ``registered_tools`` is provided and the skill
|
|
is a flow template with a dangling tool reference, also returns
|
|
``None`` (treat as unavailable per task 2.6).
|
|
"""
|
|
effective = self._effective_active(active_subscriptions)
|
|
if not effective:
|
|
return None
|
|
with self._connect() as conn:
|
|
row = conn.execute(
|
|
f"""
|
|
SELECT * FROM skills
|
|
WHERE id = ? AND subscription_id IN ({_placeholders(effective)})
|
|
""",
|
|
(skill_id, *effective),
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
skill = _row_to_skill(row)
|
|
if (
|
|
isinstance(skill, FlowTemplateSkill)
|
|
and registered_tools is not None
|
|
and not validate_flow_template_tools(skill, registered_tools)
|
|
):
|
|
return None
|
|
return skill
|
|
|
|
# ------------------------------------------------------------------
|
|
# Sync-only write API (private by convention; only api.skill_sync uses it)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _register_subscription(
|
|
self,
|
|
subscription_id: str,
|
|
*,
|
|
active: bool = True,
|
|
) -> None:
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO subscriptions
|
|
(id, active, last_synced_version, last_error, last_synced_at)
|
|
VALUES (?, ?, NULL, NULL, NULL)
|
|
ON CONFLICT(id) DO NOTHING
|
|
""",
|
|
(subscription_id, 1 if active else 0),
|
|
)
|
|
|
|
def _get_subscription_version(self, subscription_id: str) -> int | None:
|
|
"""Last successfully applied ``latest_version`` for a subscription."""
|
|
with self._connect() as conn:
|
|
row = conn.execute(
|
|
"SELECT last_synced_version FROM subscriptions WHERE id = ?",
|
|
(subscription_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
value = row["last_synced_version"]
|
|
return int(value) if value is not None else None
|
|
|
|
def _set_subscription_state(
|
|
self,
|
|
subscription_id: str,
|
|
*,
|
|
active: bool | None = None,
|
|
last_synced_version: int | None = None,
|
|
last_error: str | None = None,
|
|
bump_synced_at: bool = True,
|
|
) -> None:
|
|
updates: dict[str, Any] = {}
|
|
if bump_synced_at:
|
|
updates["last_synced_at"] = utc_now().isoformat()
|
|
if active is not None:
|
|
updates["active"] = 1 if active else 0
|
|
if last_synced_version is not None:
|
|
updates["last_synced_version"] = last_synced_version
|
|
if last_error is not None:
|
|
updates["last_error"] = last_error
|
|
if not updates:
|
|
return
|
|
assignments = ", ".join(f"{key} = ?" for key in updates)
|
|
values = (*updates.values(), subscription_id)
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
f"UPDATE subscriptions SET {assignments} WHERE id = ?",
|
|
values,
|
|
)
|
|
|
|
def _apply_sync_upsert(
|
|
self,
|
|
skill: Skill,
|
|
subscription_id: str,
|
|
) -> None:
|
|
with self._connect() as conn:
|
|
self._upsert_skill_row(conn, skill, subscription_id)
|
|
|
|
def _apply_sync_remove(self, skill_id: str) -> None:
|
|
with self._connect() as conn:
|
|
conn.execute("DELETE FROM skills WHERE id = ?", (skill_id,))
|
|
|
|
def _apply_sync_replace_all(
|
|
self,
|
|
subscription_id: str,
|
|
skills: list[Skill],
|
|
*,
|
|
latest_version: int | None = None,
|
|
last_error: str | None = None,
|
|
) -> None:
|
|
"""Atomically replace all skills belonging to one subscription."""
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"DELETE FROM skills WHERE subscription_id = ?",
|
|
(subscription_id,),
|
|
)
|
|
for skill in skills:
|
|
self._upsert_skill_row(conn, skill, subscription_id)
|
|
conn.execute(
|
|
"""
|
|
UPDATE subscriptions
|
|
SET last_synced_at = ?,
|
|
last_synced_version = ?,
|
|
last_error = ?
|
|
WHERE id = ?
|
|
""",
|
|
(
|
|
utc_now().isoformat(),
|
|
latest_version,
|
|
last_error,
|
|
subscription_id,
|
|
),
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Internals
|
|
# ------------------------------------------------------------------
|
|
|
|
def _effective_active(self, requested: set[str]) -> set[str]:
|
|
"""Query-time revocation re-check (design D4).
|
|
|
|
Intersect the caller-requested subscriptions with those still marked
|
|
``active = 1`` in the subscriptions table, so a revocation takes
|
|
effect on the next query, not just the next sync.
|
|
"""
|
|
if not requested:
|
|
return set()
|
|
with self._connect() as conn:
|
|
rows = conn.execute(
|
|
f"""
|
|
SELECT id FROM subscriptions
|
|
WHERE active = 1 AND id IN ({_placeholders(requested)})
|
|
""",
|
|
tuple(requested),
|
|
).fetchall()
|
|
return {row["id"] for row in rows}
|
|
|
|
def _upsert_skill_row(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
skill: Skill,
|
|
subscription_id: str,
|
|
) -> None:
|
|
meta = skill.metadata
|
|
if isinstance(skill, KnowledgeSkill):
|
|
content = skill.content
|
|
steps_payload: list[dict[str, Any]] = []
|
|
parameters_payload: dict[str, dict[str, Any]] = {}
|
|
else:
|
|
content = ""
|
|
steps_payload = [step.to_dict() for step in skill.steps]
|
|
parameters_payload = {
|
|
name: dict(schema) for name, schema in skill.parameters.items()
|
|
}
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO skills (
|
|
id, subscription_id, name, description, kind, tags_json,
|
|
version, source, created_at, updated_at,
|
|
content, steps_json, parameters_json
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
subscription_id = excluded.subscription_id,
|
|
name = excluded.name,
|
|
description = excluded.description,
|
|
kind = excluded.kind,
|
|
tags_json = excluded.tags_json,
|
|
version = excluded.version,
|
|
source = excluded.source,
|
|
created_at = excluded.created_at,
|
|
updated_at = excluded.updated_at,
|
|
content = excluded.content,
|
|
steps_json = excluded.steps_json,
|
|
parameters_json = excluded.parameters_json
|
|
""",
|
|
(
|
|
meta.id,
|
|
subscription_id,
|
|
meta.name,
|
|
meta.description,
|
|
meta.kind,
|
|
json.dumps(list(meta.tags)),
|
|
meta.version,
|
|
meta.source,
|
|
meta.created_at.isoformat(),
|
|
meta.updated_at.isoformat(),
|
|
content,
|
|
json.dumps(steps_payload),
|
|
json.dumps(parameters_payload),
|
|
),
|
|
)
|
|
|
|
def _ensure_schema(self) -> None:
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS subscriptions (
|
|
id TEXT PRIMARY KEY,
|
|
active INTEGER NOT NULL DEFAULT 1,
|
|
last_synced_version INTEGER,
|
|
last_error TEXT,
|
|
last_synced_at TEXT
|
|
)
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS skills (
|
|
id TEXT PRIMARY KEY,
|
|
subscription_id TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
kind TEXT NOT NULL,
|
|
tags_json TEXT NOT NULL DEFAULT '[]',
|
|
version INTEGER NOT NULL DEFAULT 1,
|
|
source TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
content TEXT NOT NULL DEFAULT '',
|
|
steps_json TEXT NOT NULL DEFAULT '[]',
|
|
parameters_json TEXT NOT NULL DEFAULT '{}'
|
|
)
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_skills_subscription "
|
|
"ON skills(subscription_id)"
|
|
)
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_skills_name ON skills(name)"
|
|
)
|
|
|
|
def _connect(self) -> sqlite3.Connection:
|
|
connection = sqlite3.connect(self.db_path)
|
|
connection.row_factory = sqlite3.Row
|
|
return connection
|
|
|
|
|
|
def validate_flow_template_tools(
|
|
skill: FlowTemplateSkill,
|
|
registered_tools: set[str],
|
|
) -> bool:
|
|
"""Return ``True`` iff every step's ``tool_name`` is registered."""
|
|
return all(step.tool_name in registered_tools for step in skill.steps)
|
|
|
|
|
|
def _placeholders(values: "sized") -> str:
|
|
return ", ".join("?" for _ in values)
|
|
|
|
|
|
def _row_to_metadata(row: sqlite3.Row) -> SkillMetadata:
|
|
return SkillMetadata.from_dict(
|
|
{
|
|
"id": row["id"],
|
|
"name": row["name"],
|
|
"description": row["description"],
|
|
"kind": row["kind"],
|
|
"tags": json.loads(row["tags_json"] or "[]"),
|
|
"version": row["version"],
|
|
"source": row["source"],
|
|
"created_at": row["created_at"],
|
|
"updated_at": row["updated_at"],
|
|
}
|
|
)
|
|
|
|
|
|
def _row_to_skill(row: sqlite3.Row) -> KnowledgeSkill | FlowTemplateSkill:
|
|
data = {
|
|
"id": row["id"],
|
|
"name": row["name"],
|
|
"description": row["description"],
|
|
"kind": row["kind"],
|
|
"tags": json.loads(row["tags_json"] or "[]"),
|
|
"version": row["version"],
|
|
"source": row["source"],
|
|
"created_at": row["created_at"],
|
|
"updated_at": row["updated_at"],
|
|
"content": row["content"],
|
|
"steps": json.loads(row["steps_json"] or "[]"),
|
|
"parameters": json.loads(row["parameters_json"] or "{}"),
|
|
}
|
|
if row["kind"] == "knowledge":
|
|
return KnowledgeSkill.from_dict(data)
|
|
return FlowTemplateSkill.from_dict(data)
|
|
|
|
|
|
def _rank_by_relevance(metas: list[SkillMetadata], query: str) -> None:
|
|
"""Stable relevance ranking: name-prefix → name-contains → tag/desc match."""
|
|
|
|
lowered = query.lower()
|
|
|
|
def tier(meta: SkillMetadata) -> int:
|
|
name = meta.name.lower()
|
|
if name.startswith(lowered):
|
|
return 0
|
|
if lowered in name:
|
|
return 1
|
|
return 2
|
|
|
|
metas.sort(key=lambda m: (tier(m), m.name))
|
|
|
|
|
|
# Lightweight type alias for the sized-collection parameter to ``_placeholders``.
|
|
sized = Any # avoid typing.Sized import churn; parameter is only used for len()
|