Files
q792602257andClaude Opus 4.6 a8ba2312fc feat(skills): cloud sync client + incremental sync + fork-on-revocation
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>
2026-07-15 07:55:08 +08:00

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()