feat(skill-catalog-subscription): synced catalog + HTTP sync + MCP tools
Consumes the external Subscription Platform as source of truth for skill content; reuses skills_learning domain models (extended with KnowledgeSkill) and workflow.skill_exec resolver. HTTP/MCP deps land in api/ per CONSTITUTION.md; synced skills use a physically separate SQLite file (tasks/skills.sqlite3) to preserve the skill-authoring capability boundary. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
"""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 _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()
|
||||
Reference in New Issue
Block a user