diff --git a/.env.example b/.env.example index 508aa301c..68bfb0647 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,11 @@ POWERCONTEXT_SERVER_DATABASE_VEC1_EXTENSION=.powercontext/vec1.so # POWERCONTEXT_SERVER_DATABASE_KIND=oceanbase # POWERCONTEXT_SERVER_DATABASE_URL=mysql+aoceanbase://user:password@host:2881/powercontext?charset=utf8mb4 +# To use embedded seekDB instead, install powercontext[server,seekdb] and replace the SQLite database values. +# Omit DATABASE_PATH to use the Server data directory's seekdb subdirectory ($POWERCONTEXT_HOME/seekdb when set). +# POWERCONTEXT_SERVER_DATABASE_KIND=seekdb +# POWERCONTEXT_SERVER_DATABASE_PATH=.powercontext/seekdb + # Source windows are flushed explicitly unless the optional interval is set. POWERCONTEXT_SERVER_RUNTIME_SOURCE_WINDOW_LIMIT=100 # POWERCONTEXT_SERVER_RUNTIME_SCHEDULE_SECONDS=60 diff --git a/docs/en/docs/how-to/install-and-run.md b/docs/en/docs/how-to/install-and-run.md index 953427901..2f63ca3da 100644 --- a/docs/en/docs/how-to/install-and-run.md +++ b/docs/en/docs/how-to/install-and-run.md @@ -49,6 +49,39 @@ disable the Dashboard explicitly. `Ctrl-C` performs a clean shutdown. Restarting the command reopens the same database. +## Use embedded seekDB + +Embedded seekDB is available on Linux and macOS when a compatible `pylibseekdb` wheel is available. Windows does not +support this embedded backend. Install or replace the tool with the optional seekDB extra: + +```bash +uv tool install --force "powercontext[cli,server,seekdb] @ git+https://github.com/oceanbase/powercontext.git@master" +``` + +When switching from SQLite, remove `POWERCONTEXT_SERVER_DATABASE_URL` and +`POWERCONTEXT_SERVER_DATABASE_VEC1_EXTENSION` from `.env`, or unset them in the shell. Those settings are not valid +for seekDB. Then select the backend and start the Server: + +```bash +unset POWERCONTEXT_SERVER_DATABASE_URL +unset POWERCONTEXT_SERVER_DATABASE_VEC1_EXTENSION +export POWERCONTEXT_SERVER_DATABASE_KIND=seekdb +powercontext server run +``` + +PowerContext always uses seekDB's built-in `test` database. Leave `POWERCONTEXT_SERVER_DATABASE_PATH` unset to store +the instance in the `seekdb` subdirectory of the PowerContext user data directory. If `POWERCONTEXT_HOME` is set, the +default is `$POWERCONTEXT_HOME/seekdb`; set `POWERCONTEXT_SERVER_DATABASE_PATH` only when a different location is +required. + +In another terminal, verify that the Server and database are ready: + +```bash +powercontext doctor +powercontext ready +powercontext capabilities +``` + ## Verify the installation ```bash diff --git a/docs/zh/docs/how-to/install-and-run.md b/docs/zh/docs/how-to/install-and-run.md index 20ae8fbd1..260fa5d60 100644 --- a/docs/zh/docs/how-to/install-and-run.md +++ b/docs/zh/docs/how-to/install-and-run.md @@ -46,6 +46,38 @@ powercontext server run 按 `Ctrl-C` 可正常关闭。再次运行该命令会打开同一个数据库。 +## 使用嵌入式 seekDB + +在有兼容 `pylibseekdb` wheel 的 Linux 和 macOS 系统上可以使用嵌入式 seekDB;Windows 不支持该嵌入式 +后端。安装或替换工具时加入可选的 seekDB extra: + +```bash +uv tool install --force "powercontext[cli,server,seekdb] @ git+https://github.com/oceanbase/powercontext.git@master" +``` + +从 SQLite 切换时,需要从 `.env` 中删除 `POWERCONTEXT_SERVER_DATABASE_URL` 和 +`POWERCONTEXT_SERVER_DATABASE_VEC1_EXTENSION`,或在 shell 中取消这两个变量;seekDB 不接受这些配置。 +然后选择 seekDB 后端并启动 Server: + +```bash +unset POWERCONTEXT_SERVER_DATABASE_URL +unset POWERCONTEXT_SERVER_DATABASE_VEC1_EXTENSION +export POWERCONTEXT_SERVER_DATABASE_KIND=seekdb +powercontext server run +``` + +PowerContext 固定使用 seekDB 内置的 `test` 数据库。未设置 `POWERCONTEXT_SERVER_DATABASE_PATH` 时,实例保存在 +PowerContext 用户数据目录的 `seekdb` 子目录中;如果设置了 `POWERCONTEXT_HOME`,默认路径为 +`$POWERCONTEXT_HOME/seekdb`。只有需要其他位置时才设置 `POWERCONTEXT_SERVER_DATABASE_PATH`。 + +在另一个终端确认 Server 和数据库已经就绪: + +```bash +powercontext doctor +powercontext ready +powercontext capabilities +``` + ## 验证安装 ```bash diff --git a/e2e/bub/uv.lock b/e2e/bub/uv.lock index 3e803e6c5..316269ba3 100644 --- a/e2e/bub/uv.lock +++ b/e2e/bub/uv.lock @@ -1615,8 +1615,10 @@ client = [ [package.metadata] requires-dist = [ { name = "aiosqlite", marker = "extra == 'builtin'", specifier = ">=0.22,<1" }, + { name = "aiosqlite", marker = "extra == 'seekdb'", specifier = ">=0.22,<1" }, { name = "aiosqlite", marker = "extra == 'server'", specifier = ">=0.22,<1" }, { name = "apscheduler", marker = "extra == 'builtin'", specifier = ">=3.11,<4" }, + { name = "apscheduler", marker = "extra == 'seekdb'", specifier = ">=3.11,<4" }, { name = "apscheduler", marker = "extra == 'server'", specifier = ">=3.11,<4" }, { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.115,<1" }, { name = "fastmcp", marker = "extra == 'server'", specifier = ">=3.4,<4" }, @@ -1633,20 +1635,25 @@ requires-dist = [ { name = "prometheus-client", marker = "extra == 'server'", specifier = ">=0.21,<1" }, { name = "pydantic", specifier = ">=2.10,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'builtin'", specifier = ">=2.27.1,<3" }, + { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'seekdb'", specifier = ">=2.27.1,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'server'", specifier = ">=2.27.1,<3" }, { name = "pydantic-settings", marker = "extra == 'builtin'", specifier = ">=2.7,<3" }, { name = "pydantic-settings", marker = "extra == 'cli'", specifier = ">=2.7,<3" }, { name = "pydantic-settings", marker = "extra == 'client'", specifier = ">=2.7,<3" }, + { name = "pydantic-settings", marker = "extra == 'seekdb'", specifier = ">=2.7,<3" }, { name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.7,<3" }, + { name = "pylibseekdb", marker = "(sys_platform == 'darwin' and extra == 'seekdb') or (sys_platform == 'linux' and extra == 'seekdb')", specifier = ">=1.3.0.post4,<2" }, { name = "pyobvector", marker = "extra == 'builtin'", specifier = ">=0.2.28,<0.3" }, + { name = "pyobvector", marker = "extra == 'seekdb'", specifier = ">=0.2.28,<0.3" }, { name = "pyobvector", marker = "extra == 'server'", specifier = ">=0.2.28,<0.3" }, { name = "rfc8785", specifier = ">=0.1.4,<1" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'builtin'", specifier = ">=2,<3" }, + { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'seekdb'", specifier = ">=2,<3" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'server'", specifier = ">=2,<3" }, { name = "typer", marker = "extra == 'cli'", specifier = ">=0.16,<1" }, { name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.34,<1" }, ] -provides-extras = ["builtin", "cli", "client", "server", "tracing-otlp"] +provides-extras = ["builtin", "cli", "client", "seekdb", "server", "tracing-otlp"] [package.metadata.requires-dev] dev = [ diff --git a/pyproject.toml b/pyproject.toml index 8cf2989e9..da5c52cd9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,10 @@ builtin = [ "pyobvector>=0.2.28,<0.3", "sqlalchemy[asyncio]>=2,<3", ] +seekdb = [ + "powercontext[builtin]", + "pylibseekdb>=1.3.0.post4,<2; sys_platform == 'linux' or sys_platform == 'darwin'", +] client = [ "httpx[socks]>=0.28,<1", "opentelemetry-api>=1.30,<2", diff --git a/src/powercontext/builtin/persistence/seekdb/__init__.py b/src/powercontext/builtin/persistence/seekdb/__init__.py new file mode 100644 index 000000000..60fd021b7 --- /dev/null +++ b/src/powercontext/builtin/persistence/seekdb/__init__.py @@ -0,0 +1,27 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Embedded seekDB async relational profile.""" + +from powercontext.builtin.persistence.seekdb.profile import ( + SeekDBConfig, + SeekDBProfile, + SeekDBUnavailableError, +) + +__all__ = ( + "SeekDBConfig", + "SeekDBProfile", + "SeekDBUnavailableError", +) diff --git a/src/powercontext/builtin/persistence/seekdb/profile.py b/src/powercontext/builtin/persistence/seekdb/profile.py new file mode 100644 index 000000000..85d38128e --- /dev/null +++ b/src/powercontext/builtin/persistence/seekdb/profile.py @@ -0,0 +1,175 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Embedded seekDB profile using its local runtime and async MySQL socket.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Mapping +from contextlib import asynccontextmanager, suppress +from importlib import import_module +from pathlib import Path +from types import ModuleType +from typing import Any, Literal, Protocol, cast + +from pydantic import BaseModel, ConfigDict, field_validator +from pyobvector import AsyncOceanBaseDialect +from sqlalchemy import Table +from sqlalchemy.dialects import registry as dialect_registry +from sqlalchemy.engine import URL +from sqlalchemy.engine.interfaces import DBAPIConnection +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine + +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.errors import PersistenceError +from powercontext.builtin.persistence.schema import create_tables + +_DIALECT_DRIVER = "mysql+aseekdb" +_DIALECT_REGISTRY_NAME = "mysql.aseekdb" + + +class AsyncSeekDBDialect(AsyncOceanBaseDialect): + """OceanBase-compatible dialect with seekDB-safe connection shutdown.""" + + supports_statement_cache = AsyncOceanBaseDialect.supports_statement_cache + + def do_close(self, dbapi_connection: DBAPIConnection) -> None: + # seekDB resets the socket while aiomysql drains COM_QUIT. SQLAlchemy's + # terminate path handles that reset and falls back to closing the transport. + self.do_terminate(dbapi_connection) + + +class _SeekDBInstance(Protocol): + def connection_options(self) -> Mapping[str, object]: ... + + def close(self) -> None: ... + + +class SeekDBUnavailableError(PersistenceError): + """Raised when the embedded seekDB binding is unavailable.""" + + def __init__(self) -> None: + super().__init__("Embedded seekDB requires powercontext[seekdb] on a supported Linux or macOS platform") + + +class SeekDBConfig(BaseModel): + """Validated configuration for one embedded seekDB instance.""" + + model_config = ConfigDict(extra="forbid") + + kind: Literal["seekdb"] = "seekdb" + path: Path + database: Literal["test"] = "test" + echo: bool = False + pool_pre_ping: bool = True + + @field_validator("path", mode="before") + @classmethod + def require_path(cls, value: object) -> object: + if isinstance(value, str) and not value.strip(): + raise ValueError("seekDB path must not be empty") # noqa: TRY003 + return value + + +class SeekDBProfile: + """An initialized embedded seekDB profile with explicit runtime ownership.""" + + def __init__(self, *, database: AsyncDatabase, tables: tuple[Table, ...]) -> None: + self.database = database + self.tables = tables + + @classmethod + @asynccontextmanager + async def open( + cls, + config: SeekDBConfig, + *, + tables: tuple[Table, ...], + ) -> AsyncIterator[SeekDBProfile]: + """Start seekDB locally and connect through its async Unix socket.""" + + path = config.path.expanduser().resolve() + path.parent.mkdir(parents=True, exist_ok=True) + module = _load_binding() + instance = await _open_instance(module, path) + try: + engine = _create_engine(config, instance.connection_options()) + database = AsyncDatabase.own(engine) + profile = cls(database=database, tables=tables) + try: + async with database.transaction() as connection: + await create_tables(connection, tables) + yield profile + finally: + close_task = asyncio.create_task(database.close()) + try: + await asyncio.shield(close_task) + except asyncio.CancelledError: + await close_task + raise + finally: + instance.close() + + +def _load_binding() -> ModuleType: + try: + return import_module("pylibseekdb") + except ModuleNotFoundError as error: + if error.name != "pylibseekdb": + raise + raise SeekDBUnavailableError from None + + +async def _open_instance(module: ModuleType, path: Path) -> _SeekDBInstance: + open_task = asyncio.create_task(cast(Any, module).aopen(str(path))) + try: + return cast(_SeekDBInstance, await asyncio.shield(open_task)) + except asyncio.CancelledError: + with suppress(BaseException): + instance = cast(_SeekDBInstance, await open_task) + instance.close() + raise + + +def _create_engine(config: SeekDBConfig, connection_options: Mapping[str, object]) -> AsyncEngine: + options = dict(connection_options) + username = str(options.pop("user", "root")) + password_value = options.pop("password", None) + host = str(options.pop("host", "localhost")) + port_value = options.pop("port", None) + # seekDB's handshake currently omits the autocommit status flag, so + # aiomysql otherwise mistakes the default-on session for an explicit + # transaction and rollback becomes ineffective. + options["init_command"] = "SET autocommit = 0" + url = URL.create( + _DIALECT_DRIVER, + username=username, + password=None if password_value is None else str(password_value), + host=host, + port=None if port_value is None else int(cast(int | str, port_value)), + database=config.database, + query={"charset": "utf8mb4"}, + ) + _register_seekdb_dialect() + return create_async_engine( + url, + connect_args=options, + echo=config.echo, + pool_pre_ping=config.pool_pre_ping, + ) + + +def _register_seekdb_dialect() -> None: + dialect_registry.register(_DIALECT_REGISTRY_NAME, __name__, "AsyncSeekDBDialect") diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 14f3b3637..b26d9855d 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -52,6 +52,7 @@ OceanBaseMemoryVectorIndex, ) from powercontext.builtin.persistence.oceanbase.profile import OceanBaseConfig, OceanBaseProfile +from powercontext.builtin.persistence.seekdb.profile import SeekDBConfig, SeekDBProfile from powercontext.builtin.persistence.sqlite.experience_index import SQLiteExperienceFTSIndex from powercontext.builtin.persistence.sqlite.memory_index import SQLiteMemoryFTSIndex, SQLiteMemoryVec1Index from powercontext.builtin.persistence.sqlite.profile import SQLiteConfig, SQLiteProfile @@ -345,18 +346,19 @@ async def open_builtin_contexts( memory_rerank_candidate_limit=config.runtime.memory_rerank_candidate_limit, ) return - if not isinstance(database, OceanBaseConfig): - raise BuiltinConfigurationError("database") - experience_index = OceanBaseExperienceFTSIndex() indexes = [OceanBaseMemoryFTSIndex()] if embedding_model is not None: indexes.append(OceanBaseMemoryVectorIndex(embedding_model.profile)) index = CompositeMemoryIndex(*indexes) - async with OceanBaseProfile.open( - database, - tables=BUILTIN_TABLES + report_tables + index.tables, - ) as profile: + tables = BUILTIN_TABLES + report_tables + index.tables + if isinstance(database, OceanBaseConfig): + profile_context = OceanBaseProfile.open(database, tables=tables) + elif isinstance(database, SeekDBConfig): + profile_context = SeekDBProfile.open(database, tables=tables) + else: + raise BuiltinConfigurationError("database") + async with profile_context as profile: async with profile.database.transaction() as connection: await index.initialize(connection) await experience_index.initialize(connection) diff --git a/src/powercontext/builtin/runtime/config.py b/src/powercontext/builtin/runtime/config.py index 04b0b3451..02b69dd2a 100644 --- a/src/powercontext/builtin/runtime/config.py +++ b/src/powercontext/builtin/runtime/config.py @@ -24,6 +24,7 @@ from powercontext.builtin.artifacts.memory.prompts import MemoryExtractionProfile from powercontext.builtin.artifacts.skill import CodexSkillRoot from powercontext.builtin.persistence.oceanbase import OceanBaseConfig +from powercontext.builtin.persistence.seekdb import SeekDBConfig from powercontext.builtin.persistence.sqlite import SQLiteConfig @@ -109,7 +110,7 @@ def require_host_for_roots(self) -> ExternalSkillsConfig: return self -DatabaseConfig = SQLiteConfig | OceanBaseConfig +DatabaseConfig = SQLiteConfig | OceanBaseConfig | SeekDBConfig def normalize_database_discriminator(value: Any) -> Any: diff --git a/src/powercontext/paths.py b/src/powercontext/paths.py index 76736131d..1263be39e 100644 --- a/src/powercontext/paths.py +++ b/src/powercontext/paths.py @@ -39,6 +39,12 @@ def default_database_path() -> Path: return powercontext_data_dir() / "powercontext.db" +def default_seekdb_path() -> Path: + """Return the installed Server's default embedded seekDB directory.""" + + return powercontext_data_dir() / "seekdb" + + def default_scheduler_path() -> Path: """Return the installed Server's default scheduler database path.""" @@ -55,6 +61,7 @@ def sqlite_url(path: Path) -> str: "POWERCONTEXT_HOME_ENV", "default_database_path", "default_scheduler_path", + "default_seekdb_path", "powercontext_data_dir", "sqlite_url", ] diff --git a/src/powercontext/server/settings.py b/src/powercontext/server/settings.py index b9da1d058..de51ae894 100644 --- a/src/powercontext/server/settings.py +++ b/src/powercontext/server/settings.py @@ -30,7 +30,7 @@ InferenceConfig, RuntimeConfig, ) -from powercontext.paths import default_database_path, sqlite_url +from powercontext.paths import default_database_path, default_seekdb_path, sqlite_url def _default_database() -> SQLiteConfig: @@ -152,6 +152,18 @@ class ServerSettings(BaseSettings): inference: InferenceConfig = Field(default_factory=InferenceConfig) external_skills: ExternalSkillsConfig = Field(default_factory=ExternalSkillsConfig) + @field_validator("database", mode="before") + @classmethod + def default_seekdb_database_path(cls, value: object) -> object: + if not isinstance(value, Mapping) or value.get("kind") != "seekdb": + return value + path = value.get("path") + if "path" in value and not (isinstance(path, str) and not path.strip()): + return value + normalized = dict(value) + normalized["path"] = default_seekdb_path() + return normalized + @field_validator("database", mode="before") @classmethod def default_database_to_sqlite(cls, value: object) -> object: diff --git a/tests/builtin/persistence/test_seekdb_profile.py b/tests/builtin/persistence/test_seekdb_profile.py new file mode 100644 index 000000000..e41c3f139 --- /dev/null +++ b/tests/builtin/persistence/test_seekdb_profile.py @@ -0,0 +1,248 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +from contextlib import AbstractAsyncContextManager +from types import TracebackType +from typing import cast + +import pytest +from pydantic import ValidationError +from sqlalchemy import Table +from sqlalchemy.engine import URL, make_url +from sqlalchemy.engine.interfaces import DBAPIConnection +from sqlalchemy.ext.asyncio import AsyncEngine + +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.seekdb import SeekDBConfig, SeekDBProfile +from powercontext.builtin.persistence.seekdb import profile as seekdb_profile_module + + +class _Begin(AbstractAsyncContextManager[object]): + async def __aenter__(self) -> object: + return object() + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + return None + + +class _TrackedEngine: + def __init__(self, events: list[str]) -> None: + self.events = events + + def begin(self) -> _Begin: + return _Begin() + + async def dispose(self) -> None: + self.events.append("engine.dispose") + + +class _SeekDBInstance: + def __init__(self, events: list[str]) -> None: + self.events = events + + def connection_options(self) -> dict[str, object]: + return {"user": "root", "unix_socket": "seekdb.sock"} + + def close(self) -> None: + self.events.append("instance.close") + + +class _SeekDBModule: + def __init__(self, instance: _SeekDBInstance, events: list[str]) -> None: + self.instance = instance + self.events = events + + async def aopen(self, path: str) -> _SeekDBInstance: + self.events.append(f"aopen:{path}") + return self.instance + + +class _TerminatingConnection: + def __init__(self) -> None: + self.terminated = False + + def terminate(self) -> None: + self.terminated = True + + +def test_config_requires_an_explicit_non_empty_path() -> None: + with pytest.raises(ValidationError, match="path"): + SeekDBConfig.model_validate({}) + with pytest.raises(ValidationError, match="seekDB path must not be empty"): + SeekDBConfig.model_validate({"path": ""}) + + +def test_dialect_terminates_connections_on_close() -> None: + connection = _TerminatingConnection() + + seekdb_profile_module.AsyncSeekDBDialect().do_close(cast(DBAPIConnection, connection)) + + assert connection.terminated + + +def test_engine_uses_the_local_socket(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + expected_engine = cast(AsyncEngine, object()) + + def create_engine(url: object, **options: object) -> AsyncEngine: + captured["url"] = url + captured.update(options) + return expected_engine + + monkeypatch.setattr(seekdb_profile_module, "create_async_engine", create_engine) + monkeypatch.setattr(seekdb_profile_module, "_register_seekdb_dialect", lambda: None) + + engine = seekdb_profile_module._create_engine( + SeekDBConfig(path=tmp_path), + {"user": "root", "unix_socket": "seekdb.sock"}, + ) + + captured_url = captured["url"] + assert isinstance(captured_url, (str, URL)) + url = make_url(captured_url) + assert engine is expected_engine + assert url.drivername == "mysql+aseekdb" + assert url.username == "root" + assert url.host == "localhost" + assert url.database == "test" + assert url.query["charset"] == "utf8mb4" + assert captured["connect_args"] == { + "init_command": "SET autocommit = 0", + "unix_socket": "seekdb.sock", + } + + +def test_profile_closes_engine_before_instance(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + async def scenario() -> None: + events: list[str] = [] + instance = _SeekDBInstance(events) + module = _SeekDBModule(instance, events) + engine = _TrackedEngine(events) + + async def create_no_tables(_connection: object, _tables: tuple[Table, ...]) -> None: + return None + + monkeypatch.setattr(seekdb_profile_module, "_load_binding", lambda: module) + monkeypatch.setattr(seekdb_profile_module, "_create_engine", lambda _config, _options: engine) + monkeypatch.setattr(seekdb_profile_module, "create_tables", create_no_tables) + + async with SeekDBProfile.open(SeekDBConfig(path=tmp_path / "seekdb"), tables=()): + pass + + assert events == [f"aopen:{(tmp_path / 'seekdb').resolve()}", "engine.dispose", "instance.close"] + + asyncio.run(scenario()) + + +def test_profile_finishes_shutdown_before_propagating_cancellation( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def scenario() -> None: + events: list[str] = [] + close_started = asyncio.Event() + transaction_started = asyncio.Event() + release_transaction = asyncio.Event() + instance = _SeekDBInstance(events) + module = _SeekDBModule(instance, events) + engine = _TrackedEngine(events) + original_close = AsyncDatabase.close + + async def create_no_tables(_connection: object, _tables: tuple[Table, ...]) -> None: + return None + + async def observe_close(database: AsyncDatabase) -> None: + close_started.set() + await original_close(database) + + monkeypatch.setattr(seekdb_profile_module, "_load_binding", lambda: module) + monkeypatch.setattr(seekdb_profile_module, "_create_engine", lambda _config, _options: engine) + monkeypatch.setattr(seekdb_profile_module, "create_tables", create_no_tables) + monkeypatch.setattr(AsyncDatabase, "close", observe_close) + + context = SeekDBProfile.open(SeekDBConfig(path=tmp_path / "seekdb"), tables=()) + profile = await context.__aenter__() + + async def hold_transaction() -> None: + async with profile.database.transaction(): + events.append("transaction.active") + transaction_started.set() + await release_transaction.wait() + events.append("transaction.complete") + + transaction_task = asyncio.create_task(hold_transaction()) + await transaction_started.wait() + shutdown_task = asyncio.create_task(context.__aexit__(None, None, None)) + await close_started.wait() + + shutdown_task.cancel() + await asyncio.sleep(0) + + assert not shutdown_task.done() + assert "instance.close" not in events + + release_transaction.set() + await transaction_task + with pytest.raises(asyncio.CancelledError): + await shutdown_task + + assert events == [ + f"aopen:{(tmp_path / 'seekdb').resolve()}", + "transaction.active", + "transaction.complete", + "engine.dispose", + "instance.close", + ] + + asyncio.run(scenario()) + + +def test_profile_closes_instance_if_open_is_cancelled(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + async def scenario() -> None: + events: list[str] = [] + started = asyncio.Event() + finish = asyncio.Event() + instance = _SeekDBInstance(events) + + class DelayedModule: + async def aopen(self, path: str) -> _SeekDBInstance: + events.append(f"aopen:{path}") + started.set() + await finish.wait() + return instance + + monkeypatch.setattr(seekdb_profile_module, "_load_binding", DelayedModule) + + async def open_profile() -> None: + async with SeekDBProfile.open(SeekDBConfig(path=tmp_path / "seekdb"), tables=()): + pass + + task = asyncio.create_task(open_profile()) + await started.wait() + task.cancel() + finish.set() + with pytest.raises(asyncio.CancelledError): + await task + + assert events == [f"aopen:{(tmp_path / 'seekdb').resolve()}", "instance.close"] + + asyncio.run(scenario()) diff --git a/tests/e2e/real_experience_skill/harness.py b/tests/e2e/real_experience_skill/harness.py index 5784b6c9b..280019f57 100644 --- a/tests/e2e/real_experience_skill/harness.py +++ b/tests/e2e/real_experience_skill/harness.py @@ -44,6 +44,7 @@ from powercontext.builtin.artifacts.experience import ExperienceCandidateInput, ExperienceContent from powercontext.builtin.artifacts.skill import CodexSkillRoot from powercontext.builtin.persistence.oceanbase import OceanBaseConfig, OceanBaseProfile +from powercontext.builtin.persistence.seekdb import SeekDBConfig, SeekDBProfile from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.builtin.runtime import DatabaseConfig, ExternalSkillsConfig, RuntimeConfig from powercontext.builtin.sources import ContentSource @@ -2065,7 +2066,7 @@ async def _purge_existing_harness_scopes(database: DatabaseConfig) -> dict[str, async def _discover_harness_scopes(database: DatabaseConfig) -> tuple[str, ...]: - async def discover(profile: OceanBaseProfile | SQLiteProfile) -> tuple[str, ...]: + async def discover(profile: OceanBaseProfile | SeekDBProfile | SQLiteProfile) -> tuple[str, ...]: scopes: set[str] = set() async with profile.database.transaction() as connection: for table_name in _SCOPE_TABLES: @@ -2082,6 +2083,9 @@ async def discover(profile: OceanBaseProfile | SQLiteProfile) -> tuple[str, ...] if isinstance(database, OceanBaseConfig): async with OceanBaseProfile.open(database, tables=()) as profile: return await discover(profile) + if isinstance(database, SeekDBConfig): + async with SeekDBProfile.open(database, tables=()) as profile: + return await discover(profile) async with SQLiteProfile.open(database, tables=()) as profile: return await discover(profile) @@ -2090,13 +2094,16 @@ async def _database_scope_counts( database: DatabaseConfig, scopes: tuple[str, ...], ) -> dict[str, dict[str, int]]: - async def count(profile: OceanBaseProfile | SQLiteProfile) -> dict[str, dict[str, int]]: + async def count(profile: OceanBaseProfile | SeekDBProfile | SQLiteProfile) -> dict[str, dict[str, int]]: async with profile.database.transaction() as connection: return await _scope_counts(connection, scopes) if isinstance(database, OceanBaseConfig): async with OceanBaseProfile.open(database, tables=()) as profile: return await count(profile) + if isinstance(database, SeekDBConfig): + async with SeekDBProfile.open(database, tables=()) as profile: + return await count(profile) async with SQLiteProfile.open(database, tables=()) as profile: return await count(profile) @@ -2105,7 +2112,7 @@ async def _purge_database_scopes( database: DatabaseConfig, scopes: tuple[str, ...], ) -> dict[str, object]: - async def purge(profile: OceanBaseProfile | SQLiteProfile) -> dict[str, object]: + async def purge(profile: OceanBaseProfile | SeekDBProfile | SQLiteProfile) -> dict[str, object]: async with profile.database.transaction() as connection: before = await _scope_counts(connection, scopes) if scopes: @@ -2136,6 +2143,9 @@ async def purge(profile: OceanBaseProfile | SQLiteProfile) -> dict[str, object]: if isinstance(database, OceanBaseConfig): async with OceanBaseProfile.open(database, tables=()) as profile: return await purge(profile) + if isinstance(database, SeekDBConfig): + async with SeekDBProfile.open(database, tables=()) as profile: + return await purge(profile) async with SQLiteProfile.open(database, tables=()) as profile: return await purge(profile) diff --git a/tests/test_server.py b/tests/test_server.py index 4f49fba1a..b991b4b76 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -20,7 +20,7 @@ import httpx import pytest from fastapi.testclient import TestClient -from pydantic import SecretStr +from pydantic import SecretStr, ValidationError from pydantic_ai.exceptions import ModelHTTPError from pydantic_ai.messages import ModelMessage, ModelResponse from pydantic_ai.models.function import AgentInfo, FunctionModel @@ -31,6 +31,7 @@ from powercontext.builtin.inference import EmbeddingResult, InferenceConfigurationError from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.oceanbase import OceanBaseConfig +from powercontext.builtin.persistence.seekdb import SeekDBConfig from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.runtime import InferenceConfig, MemoryExtractionProfile, RuntimeConfig from powercontext.http import ( @@ -113,6 +114,7 @@ def test_settings_load_server_environment(monkeypatch) -> None: assert settings.http.host == "127.0.0.2" assert settings.http.port == 9000 + assert isinstance(settings.database, SQLiteConfig) assert settings.database.url == "sqlite+aiosqlite:////var/lib/powercontext/test.db" assert settings.runtime.source_window_limit == 25 assert settings.runtime.memory_extraction_profile is MemoryExtractionProfile.CONVERSATION @@ -138,6 +140,7 @@ def test_server_settings_vec1_preserves_file_database(tmp_path, monkeypatch) -> settings = ServerSettings() + assert isinstance(settings.database, SQLiteConfig) assert settings.database.url == f"sqlite+aiosqlite:///{data_dir / 'powercontext.db'}" @@ -152,6 +155,53 @@ def test_server_settings_select_oceanbase(monkeypatch) -> None: assert settings.database.url.get_secret_value() == url +def test_server_settings_select_embedded_seekdb(tmp_path, monkeypatch) -> None: + data_dir = tmp_path / "powercontext-data" + monkeypatch.setenv("POWERCONTEXT_HOME", str(data_dir)) + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_KIND", "seekdb") + + settings = ServerSettings() + + assert isinstance(settings.database, SeekDBConfig) + assert settings.database.path == data_dir / "seekdb" + assert settings.database.database == "test" + assert not data_dir.exists() + + +@pytest.mark.parametrize("configured_path", ["", " "]) +def test_server_settings_default_blank_embedded_seekdb_path(configured_path, tmp_path, monkeypatch) -> None: + data_dir = tmp_path / "powercontext-data" + monkeypatch.setenv("POWERCONTEXT_HOME", str(data_dir)) + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_KIND", "seekdb") + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_PATH", configured_path) + + settings = ServerSettings() + + assert isinstance(settings.database, SeekDBConfig) + assert settings.database.path == data_dir / "seekdb" + + +def test_server_settings_override_embedded_seekdb_path(tmp_path, monkeypatch) -> None: + database_path = tmp_path / "custom-seekdb" + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_KIND", "seekdb") + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_PATH", str(database_path)) + + settings = ServerSettings() + + assert isinstance(settings.database, SeekDBConfig) + assert settings.database.path == database_path + assert settings.database.database == "test" + + +def test_server_settings_reject_custom_embedded_seekdb_database(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_KIND", "seekdb") + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_PATH", str(tmp_path / "seekdb")) + monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_DATABASE", "custom") + + with pytest.raises(ValidationError, match="Input should be 'test'"): + ServerSettings() + + def test_server_scheduler_uses_the_powercontext_data_directory(tmp_path, monkeypatch) -> None: data_dir = tmp_path / "powercontext-data" monkeypatch.setenv("POWERCONTEXT_HOME", str(data_dir)) diff --git a/uv.lock b/uv.lock index 70ab422b4..77342f536 100644 --- a/uv.lock +++ b/uv.lock @@ -1820,6 +1820,15 @@ client = [ { name = "opentelemetry-api" }, { name = "pydantic-settings" }, ] +seekdb = [ + { name = "aiosqlite" }, + { name = "apscheduler" }, + { name = "pydantic-ai-slim", extra = ["anthropic", "openai"] }, + { name = "pydantic-settings" }, + { name = "pylibseekdb", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pyobvector" }, + { name = "sqlalchemy", extra = ["asyncio"] }, +] server = [ { name = "aiosqlite" }, { name = "apscheduler" }, @@ -1870,6 +1879,7 @@ requires-dist = [ { name = "opentelemetry-sdk", marker = "extra == 'server'", specifier = ">=1.30,<2" }, { name = "platformdirs", marker = "extra == 'cli'", specifier = ">=4,<5" }, { name = "platformdirs", marker = "extra == 'server'", specifier = ">=4,<5" }, + { name = "powercontext", extras = ["builtin"], marker = "extra == 'seekdb'" }, { name = "powercontext", extras = ["builtin"], marker = "extra == 'server'" }, { name = "powercontext", extras = ["client"], marker = "extra == 'cli'" }, { name = "prometheus-client", marker = "extra == 'server'", specifier = ">=0.21,<1" }, @@ -1878,13 +1888,14 @@ requires-dist = [ { name = "pydantic-settings", marker = "extra == 'builtin'", specifier = ">=2.7,<3" }, { name = "pydantic-settings", marker = "extra == 'client'", specifier = ">=2.7,<3" }, { name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.7,<3" }, + { name = "pylibseekdb", marker = "(sys_platform == 'darwin' and extra == 'seekdb') or (sys_platform == 'linux' and extra == 'seekdb')", specifier = ">=1.3.0.post4,<2" }, { name = "pyobvector", marker = "extra == 'builtin'", specifier = ">=0.2.28,<0.3" }, { name = "rfc8785", specifier = ">=0.1.4,<1" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'builtin'", specifier = ">=2,<3" }, { name = "typer", marker = "extra == 'cli'", specifier = ">=0.16,<1" }, { name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.34,<1" }, ] -provides-extras = ["builtin", "client", "server", "tracing-otlp", "cli"] +provides-extras = ["builtin", "seekdb", "client", "server", "tracing-otlp", "cli"] [package.metadata.requires-dev] dev = [ @@ -2187,6 +2198,21 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pylibseekdb" +version = "1.3.0.post4" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/7b/6f32f431c72b79673fa2d63c0f2e073b5435f76dd96d8f0da244c1da6f18/pylibseekdb-1.3.0.post4-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:b1fc2cebc222c398044bc494d2814f59c37c2795a352c8c8f8e2638b2d3180a7", size = 127323462, upload-time = "2026-08-12T01:52:11.49Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/0d/c36fb774730d6846cd787744f302f4c0c5d4f10f77d053615c3cfd39c2f2/pylibseekdb-1.3.0.post4-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:6b81347bb6358cbbc285034bf8c90efe6c2e7271d44a3662c783ee3082612716", size = 110698053, upload-time = "2026-08-06T02:34:51.952Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4c/43/4169d9485b6de641187bf56f5549d318a4d06df01f92ae9f8dfb9b6d99a1/pylibseekdb-1.3.0.post4-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:2e59856d1f994ba9c32a58c47f368b0e271127e92e4e47f8831a3093147068ea", size = 111250532, upload-time = "2026-08-06T02:34:18.222Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/07/d49a43e1211d03fb54bc41ba777debac253bb252d65a5b9cf3771b95ab86/pylibseekdb-1.3.0.post4-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:5d6934330d218c4e3edf9d2ec9cb339f6c0a73c587883edb58c90904f6f2d6e7", size = 126090712, upload-time = "2026-08-06T02:33:13.891Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/78/75d5993ca18a3e64cc2ca4e2e8f2738865f93cfc5cebd7ec134314dc347b/pylibseekdb-1.3.0.post4-cp312-abi3-macosx_13_0_x86_64.whl", hash = "sha256:7d2065c58d7accfe8e9281487969c996d8ef8d9d6b8a2e7a3cdfc6c5363ff4d5", size = 127321326, upload-time = "2026-08-12T01:51:59.289Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/61/ae4c488692e9ad1a126c6f7af85d1480da711ff419dc54d6919052386c2a/pylibseekdb-1.3.0.post4-cp312-abi3-macosx_15_0_arm64.whl", hash = "sha256:1365f13dab633eadb2f8f305f8af638adacc5bfb9e71abd877190476d30d1589", size = 110695544, upload-time = "2026-08-06T02:35:00.77Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/a7/e84f65b0ae5eaa8e1adaad3cf7609017c4e9cfd7b957f56efcd87ce14916/pylibseekdb-1.3.0.post4-cp312-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5052794da997f3311f9b57c0bf29d492bf184118fe03829df74975bce7beecf5", size = 111246811, upload-time = "2026-08-06T02:34:28.585Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/e9/49507c0859bf0898f99f2b0978baba6cfaca9563e4ba20242be014ee7e6c/pylibseekdb-1.3.0.post4-cp312-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ee2700b5990e5e4f11c4b980b182cf7b066817ddd91c7b876152b88a7c24a7ea", size = 126086694, upload-time = "2026-08-06T02:33:28.048Z" }, +] + [[package]] name = "pymdown-extensions" version = "11.0.1"