diff --git a/packages/api/alayaos_api/main.py b/packages/api/alayaos_api/main.py index b31ab98..fce7c1f 100644 --- a/packages/api/alayaos_api/main.py +++ b/packages/api/alayaos_api/main.py @@ -36,6 +36,7 @@ def create_app() -> FastAPI: from alayaos_api.middleware import register_error_handlers from alayaos_api.routers import ( + admin, api_keys, ask, chunks, @@ -57,6 +58,7 @@ def create_app() -> FastAPI: register_error_handlers(app) + app.include_router(admin.router) app.include_router(health.router) app.include_router(workspaces.router, prefix="/api/v1", tags=["workspaces"]) app.include_router(entities.router, prefix="/api/v1", tags=["entities"]) diff --git a/packages/api/alayaos_api/routers/admin.py b/packages/api/alayaos_api/routers/admin.py new file mode 100644 index 0000000..410f9b9 --- /dev/null +++ b/packages/api/alayaos_api/routers/admin.py @@ -0,0 +1,103 @@ +"""Admin endpoints for maintenance operations.""" + +import uuid +from typing import Annotated + +import structlog +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from alayaos_api.deps import get_session, require_scope +from alayaos_core.models.api_key import APIKey +from alayaos_core.services.embedding import EmbeddingServiceInterface + +log = structlog.get_logger() +router = APIRouter(prefix="/admin", tags=["admin"]) + + +class BackfillRequest(BaseModel): + workspace_id: uuid.UUID | None = None + batch_size: int = Field(default=64, ge=1, le=200) + + +class BackfillResponse(BaseModel): + processed: int + failed: int + total: int + + +def get_embedding_service() -> EmbeddingServiceInterface: + """Provide the embedding service (FastEmbed in production).""" + from alayaos_core.config import Settings + from alayaos_core.services.embedding import FastEmbedService + + settings = Settings() + return FastEmbedService(settings.EMBEDDING_MODEL, settings.EMBEDDING_DIMENSIONS) # type: ignore[return-value] + + +@router.post("/backfill-embeddings", response_model=BackfillResponse) +async def backfill_embeddings( + request: BackfillRequest, + session: Annotated[AsyncSession, Depends(get_session)], + embedding_service: Annotated[EmbeddingServiceInterface, Depends(get_embedding_service)], + api_key: Annotated[APIKey, Depends(require_scope("admin"))], +) -> BackfillResponse: + """Backfill missing embeddings in vector_chunks. + + When workspace_id is provided, SET LOCAL app.workspace_id is applied so RLS + filters to that workspace. When omitted, this is a cross-workspace admin + operation — the admin key bypasses RLS by design. + """ + # Apply RLS workspace filter when workspace_id is provided. + if request.workspace_id is not None: + validated_wid = str(uuid.UUID(str(request.workspace_id))) + await session.execute(text(f"SET LOCAL app.workspace_id = '{validated_wid}'")) + + # Build query for chunks with no embedding + if request.workspace_id is not None: + result = await session.execute( + text( + "SELECT id, content FROM vector_chunks" + " WHERE embedding IS NULL AND workspace_id = :workspace_id" + " LIMIT :batch_size" + ), + {"workspace_id": request.workspace_id, "batch_size": request.batch_size}, + ) + else: + result = await session.execute( + text("SELECT id, content FROM vector_chunks WHERE embedding IS NULL LIMIT :batch_size"), + {"batch_size": request.batch_size}, + ) + + rows = result.all() + total = len(rows) + + if total == 0: + return BackfillResponse(processed=0, failed=0, total=0) + + texts = [row.content for row in rows] + ids = [row.id for row in rows] + + try: + embeddings = await embedding_service.embed_texts(texts) + except Exception: + log.exception("backfill.embed_failed", count=total) + return BackfillResponse(processed=0, failed=total, total=total) + + processed = 0 + failed = 0 + for chunk_id, embedding in zip(ids, embeddings, strict=True): + try: + async with session.begin_nested(): + await session.execute( + text("UPDATE vector_chunks SET embedding = :embedding WHERE id = :id"), + {"embedding": str(embedding), "id": chunk_id}, + ) + processed += 1 + except Exception: + log.warning("backfill_chunk_failed", chunk_id=str(chunk_id)) + failed += 1 + + return BackfillResponse(processed=processed, failed=failed, total=total) diff --git a/packages/api/tests/test_routers_admin.py b/packages/api/tests/test_routers_admin.py new file mode 100644 index 0000000..31d32b7 --- /dev/null +++ b/packages/api/tests/test_routers_admin.py @@ -0,0 +1,190 @@ +"""Tests for the admin router.""" + +import uuid +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +from fastapi.testclient import TestClient + +from alayaos_api.main import create_app +from alayaos_core.models.api_key import APIKey + +RAW_KEY = "ak_testprefix12345678901234567890" +PREFIX = RAW_KEY[:12] +WS_ID = uuid.UUID("12345678-1234-5678-1234-567812345678") + + +def make_api_key(scopes=None) -> APIKey: + import hashlib + + return APIKey( + id=uuid.uuid4(), + workspace_id=WS_ID, + name="Test Key", + key_prefix=PREFIX, + key_hash=hashlib.sha256(RAW_KEY.encode()).hexdigest(), + scopes=scopes or ["read", "write", "admin"], + revoked_at=None, + expires_at=None, + is_bootstrap=False, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + + +def make_app_with_mock_session(api_key: APIKey): + app = create_app() + + async def override_session(): + session = AsyncMock() + yield session + + async def override_api_key(): + return api_key + + async def override_workspace_session(): + session = AsyncMock() + yield session + + from alayaos_api.deps import get_api_key, get_session, get_workspace_session + from alayaos_api.routers.admin import get_embedding_service + from alayaos_core.services.embedding import FakeEmbeddingService + + app.dependency_overrides[get_session] = override_session + app.dependency_overrides[get_api_key] = override_api_key + app.dependency_overrides[get_workspace_session] = override_workspace_session + # Override require_scope("admin") by overriding get_api_key (used inside require_scope) + # get_api_key is already overridden above, so require_scope will pass for admin keys. + app.dependency_overrides[get_embedding_service] = lambda: FakeEmbeddingService() + return app + + +class TestBackfillEmbeddings: + def test_backfill_requires_admin_scope(self) -> None: + """Endpoint returns 403 when key lacks admin scope.""" + api_key = make_api_key(scopes=["read", "write"]) + app = make_app_with_mock_session(api_key) + + client = TestClient(app) + response = client.post("/admin/backfill-embeddings", json={}) + + assert response.status_code == 403 + + def test_backfill_returns_200_with_counts(self) -> None: + """Endpoint returns processed/failed/total counts.""" + api_key = make_api_key() + app = make_app_with_mock_session(api_key) + + # Mock the session execute calls: + # First call returns rows (chunks needing embedding) + # Second call is the UPDATE (via begin_nested savepoint) + chunk_id = uuid.uuid4() + mock_row = MagicMock() + mock_row.id = chunk_id + mock_row.content = "hello world" + + fetch_result = MagicMock() + fetch_result.all.return_value = [mock_row] + + update_result = MagicMock() + + session_mock = AsyncMock() + session_mock.execute = AsyncMock(side_effect=[fetch_result, update_result]) + # begin_nested returns an async context manager + nested_cm = AsyncMock() + nested_cm.__aenter__ = AsyncMock(return_value=nested_cm) + nested_cm.__aexit__ = AsyncMock(return_value=False) + session_mock.begin_nested = MagicMock(return_value=nested_cm) + + async def override_session(): + yield session_mock + + from alayaos_api.deps import get_session + + app.dependency_overrides[get_session] = override_session + + client = TestClient(app) + response = client.post("/admin/backfill-embeddings", json={}) + + assert response.status_code == 200 + body = response.json() + assert body["processed"] == 1 + assert body["failed"] == 0 + assert body["total"] == 1 + + def test_backfill_no_chunks_returns_zero_counts(self) -> None: + """When no chunks need embedding, all counts are zero.""" + api_key = make_api_key() + app = make_app_with_mock_session(api_key) + + fetch_result = MagicMock() + fetch_result.all.return_value = [] + + session_mock = AsyncMock() + session_mock.execute = AsyncMock(return_value=fetch_result) + + async def override_session(): + yield session_mock + + from alayaos_api.deps import get_session + + app.dependency_overrides[get_session] = override_session + + client = TestClient(app) + response = client.post("/admin/backfill-embeddings", json={}) + + assert response.status_code == 200 + body = response.json() + assert body["processed"] == 0 + assert body["failed"] == 0 + assert body["total"] == 0 + + def test_backfill_filters_by_workspace_id(self) -> None: + """When workspace_id is provided, execute is called with a filter.""" + api_key = make_api_key() + app = make_app_with_mock_session(api_key) + + fetch_result = MagicMock() + fetch_result.all.return_value = [] + + session_mock = AsyncMock() + # First call: SET LOCAL; second call: SELECT + session_mock.execute = AsyncMock(side_effect=[MagicMock(), fetch_result]) + + async def override_session(): + yield session_mock + + from alayaos_api.deps import get_session + + app.dependency_overrides[get_session] = override_session + + client = TestClient(app) + ws_id = str(uuid.uuid4()) + response = client.post( + "/admin/backfill-embeddings", + json={"workspace_id": ws_id}, + ) + + assert response.status_code == 200 + # Two execute calls: SET LOCAL + SELECT + assert session_mock.execute.call_count == 2 + + def test_backfill_batch_size_upper_bound(self) -> None: + """batch_size > 200 is rejected with 422.""" + api_key = make_api_key() + app = make_app_with_mock_session(api_key) + + client = TestClient(app) + response = client.post("/admin/backfill-embeddings", json={"batch_size": 201}) + + assert response.status_code in (400, 422) + + def test_backfill_batch_size_lower_bound(self) -> None: + """batch_size < 1 is rejected with 400 or 422.""" + api_key = make_api_key() + app = make_app_with_mock_session(api_key) + + client = TestClient(app) + response = client.post("/admin/backfill-embeddings", json={"batch_size": 0}) + + assert response.status_code in (400, 422) diff --git a/packages/cli-go/.goreleaser.yaml b/packages/cli-go/.goreleaser.yaml index 375bb2f..a805813 100644 --- a/packages/cli-go/.goreleaser.yaml +++ b/packages/cli-go/.goreleaser.yaml @@ -16,6 +16,27 @@ archives: format: zip checksum: name_template: checksums.txt +brews: + - repository: + owner: GoSync-Inc + name: homebrew-alaya + homepage: https://github.com/GoSync-Inc/alaya + description: "Alaya — corporate memory CLI" + license: BUSL-1.1 + install: | + bin.install "alaya" + test: | + system "#{bin}/alaya", "--version" + +signs: + - cmd: cosign + artifacts: checksum + args: + - "sign-blob" + - "--yes" + - "${artifact}" + - "--output-signature=${signature}" + changelog: sort: asc filters: