Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/api/alayaos_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"])
Expand Down
103 changes: 103 additions & 0 deletions packages/api/alayaos_api/routers/admin.py
Original file line number Diff line number Diff line change
@@ -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)
190 changes: 190 additions & 0 deletions packages/api/tests/test_routers_admin.py
Original file line number Diff line number Diff line change
@@ -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)
21 changes: 21 additions & 0 deletions packages/cli-go/.goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading