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