Newer
Older
navi-1 / navi / skills / store.py
"""Catalog storage for skills and protocols (PostgreSQL).

One table holds both kinds; ownership is expressed by ``owner_id`` plus
``visibility``:

- personal rows have ``owner_id = <user id>`` and only that user sees them;
- shared rows have ``owner_id = ''`` and everybody sees them, only an admin
  writes them.

Every write appends to ``skill_versions``, so the history is the audit log of
who changed a procedure and why. ``content_hash`` is what a hive-sync service
will compare later; nothing else about synchronization is decided yet.
"""

from __future__ import annotations

import asyncio
import json
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any

import asyncpg
import structlog

from navi.skills.models import (
    KINDS,
    VISIBILITIES,
    SkillDoc,
    SkillError,
    content_hash,
    parse_document,
)

log = structlog.get_logger()

_DDL = """
CREATE TABLE IF NOT EXISTS skills (
    id           TEXT PRIMARY KEY,
    kind         TEXT NOT NULL CHECK (kind IN ('skill','protocol')),
    name         TEXT NOT NULL,
    description  TEXT NOT NULL DEFAULT '',
    triggers     TEXT[] NOT NULL DEFAULT '{}',
    tags         TEXT[] NOT NULL DEFAULT '{}',
    area         TEXT NOT NULL DEFAULT 'general',
    visibility   TEXT NOT NULL DEFAULT 'personal'
                 CHECK (visibility IN ('personal','shared')),
    owner_id     TEXT NOT NULL DEFAULT '',
    content      TEXT NOT NULL,
    frontmatter  JSONB NOT NULL DEFAULT '{}'::jsonb,
    version      INTEGER NOT NULL DEFAULT 1,
    content_hash TEXT NOT NULL DEFAULT '',
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_by   TEXT NOT NULL DEFAULT ''
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_skills_owner_name
    ON skills (owner_id, kind, name);
CREATE INDEX IF NOT EXISTS idx_skills_visible
    ON skills (visibility, kind, updated_at DESC);

CREATE TABLE IF NOT EXISTS skill_versions (
    id          BIGSERIAL PRIMARY KEY,
    skill_id    TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
    version     INTEGER NOT NULL,
    content     TEXT NOT NULL,
    frontmatter JSONB NOT NULL DEFAULT '{}'::jsonb,
    content_hash TEXT NOT NULL DEFAULT '',
    edited_by   TEXT NOT NULL DEFAULT '',
    reason      TEXT,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (skill_id, version)
);
"""

_SELECT = (
    "id, kind, name, description, triggers, tags, area, visibility, owner_id, "
    "content, version, content_hash, created_at, updated_at, updated_by"
)


@dataclass
class SkillEntry:
    id: str
    kind: str
    name: str
    description: str
    triggers: list[str]
    tags: list[str]
    area: str
    visibility: str
    owner_id: str
    content: str
    version: int
    content_hash: str
    created_at: datetime | None = None
    updated_at: datetime | None = None
    updated_by: str = ""

    @property
    def doc(self) -> SkillDoc:
        return parse_document(self.content, kind=self.kind)

    def to_dict(self, *, include_content: bool = True) -> dict[str, Any]:
        data = {
            "id": self.id,
            "kind": self.kind,
            "name": self.name,
            "description": self.description,
            "triggers": self.triggers,
            "tags": self.tags,
            "area": self.area,
            "visibility": self.visibility,
            "owner_id": self.owner_id,
            "version": self.version,
            "content_hash": self.content_hash,
            "created_at": self.created_at.isoformat() if self.created_at else None,
            "updated_at": self.updated_at.isoformat() if self.updated_at else None,
            "updated_by": self.updated_by,
        }
        if include_content:
            data["content"] = self.content
        return data


def _row_to_entry(row) -> SkillEntry:
    return SkillEntry(
        id=row["id"],
        kind=row["kind"],
        name=row["name"],
        description=row["description"] or "",
        triggers=list(row["triggers"] or []),
        tags=list(row["tags"] or []),
        area=row["area"] or "general",
        visibility=row["visibility"],
        owner_id=row["owner_id"] or "",
        content=row["content"],
        version=row["version"],
        content_hash=row["content_hash"] or "",
        created_at=row["created_at"],
        updated_at=row["updated_at"],
        updated_by=row["updated_by"] or "",
    )


def _json(frontmatter: dict[str, Any]) -> str:
    return json.dumps(frontmatter, ensure_ascii=False)


class SkillStore:
    def __init__(self, pool: asyncpg.Pool) -> None:
        self._pool = pool
        self._ready = False
        self._lock = asyncio.Lock()

    async def _ensure_ready(self) -> None:
        if self._ready:
            return
        async with self._lock:
            if self._ready:
                return
            async with self._pool.acquire() as conn:
                await conn.execute(_DDL)
            self._ready = True

    # ── reads ───────────────────────────────────────────────────────────────

    async def get(self, skill_id: str) -> SkillEntry | None:
        await self._ensure_ready()
        async with self._pool.acquire() as conn:
            row = await conn.fetchrow(
                f"SELECT {_SELECT} FROM skills WHERE id = $1", skill_id
            )
        return _row_to_entry(row) if row else None

    async def find(
        self, *, user_id: str, name: str | None = None, kind: str | None = None,
        skill_id: str | None = None,
    ) -> SkillEntry | None:
        """One entry by id or by name, within what ``user_id`` may see."""
        await self._ensure_ready()
        clauses = ["(owner_id = $1 OR visibility = 'shared')"]
        args: list[Any] = [user_id]
        if skill_id:
            args.append(skill_id)
            clauses.append(f"id = ${len(args)}")
        if name:
            args.append(name)
            clauses.append(f"lower(name) = lower(${len(args)})")
        if kind:
            args.append(kind)
            clauses.append(f"kind = ${len(args)}")
        async with self._pool.acquire() as conn:
            row = await conn.fetchrow(
                f"SELECT {_SELECT} FROM skills WHERE " + " AND ".join(clauses) +
                " ORDER BY (owner_id = $1) DESC LIMIT 1",
                *args,
            )
        return _row_to_entry(row) if row else None

    async def list(
        self,
        *,
        user_id: str,
        kind: str | None = None,
        query: str | None = None,
        tag: str | None = None,
        area: str | None = None,
        limit: int = 100,
    ) -> list[SkillEntry]:
        await self._ensure_ready()
        clauses = ["(owner_id = $1 OR visibility = 'shared')"]
        args: list[Any] = [user_id]
        if kind:
            args.append(kind)
            clauses.append(f"kind = ${len(args)}")
        if query:
            args.append(f"%{query}%")
            n = len(args)
            clauses.append(
                f"(name ILIKE ${n} OR description ILIKE ${n} "
                f"OR array_to_string(triggers, ' ') ILIKE ${n} "
                f"OR array_to_string(tags, ' ') ILIKE ${n})"
            )
        if tag:
            args.append(tag)
            clauses.append(f"${len(args)} = ANY(tags)")
        if area:
            args.append(area)
            clauses.append(f"area = ${len(args)}")
        args.append(max(1, min(int(limit), 500)))
        async with self._pool.acquire() as conn:
            rows = await conn.fetch(
                f"SELECT {_SELECT} FROM skills WHERE " + " AND ".join(clauses) +
                f" ORDER BY (owner_id = $1) DESC, updated_at DESC LIMIT ${len(args)}",
                *args,
            )
        return [_row_to_entry(r) for r in rows]

    async def list_versions(self, skill_id: str, limit: int = 50) -> list[dict]:
        await self._ensure_ready()
        async with self._pool.acquire() as conn:
            rows = await conn.fetch(
                "SELECT version, content, edited_by, reason, created_at, content_hash "
                "FROM skill_versions WHERE skill_id = $1 "
                "ORDER BY version DESC LIMIT $2",
                skill_id, max(1, min(int(limit), 200)),
            )
        return [
            {
                "version": r["version"],
                "content": r["content"],
                "edited_by": r["edited_by"],
                "reason": r["reason"],
                "created_at": r["created_at"].isoformat() if r["created_at"] else None,
                "content_hash": r["content_hash"],
            }
            for r in rows
        ]

    # ── writes ──────────────────────────────────────────────────────────────

    async def create(
        self,
        *,
        owner_id: str,
        content: str,
        visibility: str = "personal",
        edited_by: str = "",
        reason: str | None = None,
        kind: str | None = None,
    ) -> SkillEntry:
        await self._ensure_ready()
        if visibility not in VISIBILITIES:
            raise SkillError(f"visibility must be one of {', '.join(VISIBILITIES)}")
        doc = parse_document(content, kind=kind)
        if visibility == "shared":
            owner_id = ""

        skill_id = uuid.uuid4().hex
        digest = content_hash(content)
        try:
            async with self._pool.acquire() as conn:
                async with conn.transaction():
                    await conn.execute(
                        """
                        INSERT INTO skills (id, kind, name, description, triggers, tags,
                                            area, visibility, owner_id, content, frontmatter,
                                            version, content_hash, updated_by)
                        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb,
                                1, $12, $13)
                        """,
                        skill_id, doc.kind, doc.name, doc.description,
                        doc.triggers, doc.tags, doc.area, visibility, owner_id,
                        content, _json(doc.frontmatter()), digest, edited_by,
                    )
                    await conn.execute(
                        """
                        INSERT INTO skill_versions (skill_id, version, content, frontmatter,
                                                    content_hash, edited_by, reason)
                        VALUES ($1, 1, $2, $3::jsonb, $4, $5, $6)
                        """,
                        skill_id, content, _json(doc.frontmatter()), digest, edited_by, reason,
                    )
        except asyncpg.UniqueViolationError as e:
            raise SkillError(
                f"a {doc.kind} named {doc.name!r} already exists in this scope"
            ) from e
        entry = await self.get(skill_id)
        assert entry is not None
        log.info(
            "skills.created", skill_id=skill_id, kind=doc.kind, name=doc.name,
            visibility=visibility, owner_id=owner_id,
        )
        return entry

    async def update(
        self,
        skill_id: str,
        *,
        content: str,
        edited_by: str = "",
        reason: str | None = None,
    ) -> SkillEntry | None:
        await self._ensure_ready()
        current = await self.get(skill_id)
        if current is None:
            return None
        doc = parse_document(content, kind=current.kind)
        digest = content_hash(content)
        new_version = current.version + 1
        try:
            async with self._pool.acquire() as conn:
                async with conn.transaction():
                    await conn.execute(
                        """
                        INSERT INTO skill_versions (skill_id, version, content, frontmatter,
                                                    content_hash, edited_by, reason)
                        VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7)
                        """,
                        skill_id, new_version, content, _json(doc.frontmatter()),
                        digest, edited_by, reason,
                    )
                    await conn.execute(
                        """
                        UPDATE skills SET name = $2, description = $3, triggers = $4,
                               tags = $5, area = $6, content = $7, frontmatter = $8::jsonb,
                               version = $9, content_hash = $10, updated_at = now(),
                               updated_by = $11
                         WHERE id = $1
                        """,
                        skill_id, doc.name, doc.description, doc.triggers, doc.tags,
                        doc.area, content, _json(doc.frontmatter()), new_version,
                        digest, edited_by,
                    )
        except asyncpg.UniqueViolationError as e:
            raise SkillError(
                f"a {doc.kind} named {doc.name!r} already exists in this scope"
            ) from e
        log.info("skills.updated", skill_id=skill_id, version=new_version, edited_by=edited_by)
        return await self.get(skill_id)

    async def delete(self, skill_id: str) -> bool:
        await self._ensure_ready()
        async with self._pool.acquire() as conn:
            status = await conn.execute("DELETE FROM skills WHERE id = $1", skill_id)
        return status.endswith(" 1")

    async def set_visibility(
        self, skill_id: str, visibility: str, *, edited_by: str = ""
    ) -> SkillEntry | None:
        """Move an entry between personal and shared.

        Promoting a personal entry moves its owner to '' (shared); demoting
        requires an owner, so the caller passes ``edited_by`` as the new owner.
        """
        await self._ensure_ready()
        if visibility not in VISIBILITIES:
            raise SkillError(f"visibility must be one of {', '.join(VISIBILITIES)}")
        owner_id = "" if visibility == "shared" else edited_by
        try:
            async with self._pool.acquire() as conn:
                await conn.execute(
                    "UPDATE skills SET visibility = $2, owner_id = $3, updated_at = now(), "
                    "updated_by = $4 WHERE id = $1",
                    skill_id, visibility, owner_id, edited_by,
                )
        except asyncpg.UniqueViolationError as e:
            raise SkillError("an entry with this name already exists in the target scope") from e
        return await self.get(skill_id)


def is_kind(value: str) -> bool:
    return value in KINDS


@dataclass
class SkillRef:
    """A compact catalog row for search results."""

    id: str
    kind: str
    name: str
    description: str
    tags: list[str] = field(default_factory=list)
    area: str = "general"
    visibility: str = "personal"
    version: int = 1

    @classmethod
    def of(cls, entry: SkillEntry) -> "SkillRef":
        return cls(
            id=entry.id, kind=entry.kind, name=entry.name,
            description=entry.description, tags=entry.tags, area=entry.area,
            visibility=entry.visibility, version=entry.version,
        )

    def line(self) -> str:
        bits = [f"[{self.kind}]", self.name]
        if self.version and self.version > 1:
            bits.append(f"v{self.version}")
        bits.append(f"({self.area}/{self.visibility})")
        line = " ".join(bits)
        if self.description:
            line += f" — {self.description}"
        if self.tags:
            line += f" [tags: {', '.join(self.tags)}]"
        line += f" id={self.id}"
        return line