From 5b6b5ddb123e665b4110646ad33dfb2f00dc9fc6 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Tue, 9 Jun 2026 02:07:00 +0800 Subject: [PATCH 1/3] fix(storage): fall back to sqlite when seekdb is unavailable Centralize platform-aware storage default selection so zero-config startup uses embedded SeekDB only when available, and falls back to SQLite otherwise. Expose memory service readiness and SQLite capability limitations through server status and the dashboard. Closes #992 --- .env.example | 9 +- .env.example.full | 17 ++- apps/claude-code-plugin/SETUP.md | 9 +- .../src/components/system-health-card.tsx | 25 ++++ dashboard/src/i18n/locales/en.json | 4 +- dashboard/src/i18n/locales/zh.json | 4 +- dashboard/src/types/api.ts | 9 ++ docs/guides/0003-configuration.md | 18 +-- src/powermem/cli/commands/config.py | 30 +++- src/powermem/config_loader.py | 22 ++- src/powermem/configs.py | 24 ++- src/powermem/core/async_memory.py | 7 +- src/powermem/core/memory.py | 7 +- src/powermem/core/setup.py | 5 +- src/powermem/integrations/llm/factory.py | 2 +- src/powermem/platform_defaults.py | 137 ++++++++++++++++++ src/powermem/storage/oceanbase/oceanbase.py | 8 +- src/server/api/v1/agents.py | 3 +- src/server/api/v1/memories.py | 3 +- src/server/api/v1/search.py | 3 +- src/server/api/v1/system.py | 46 +++++- src/server/api/v1/users.py | 3 +- src/server/main.py | 30 +++- src/server/models/response.py | 16 ++ src/server/utils/service_errors.py | 19 +++ tests/unit/test_seekdb_default_storage.py | 22 ++- tests/unit/test_storage_default_provider.py | 77 ++++++++++ 27 files changed, 484 insertions(+), 75 deletions(-) create mode 100644 src/powermem/platform_defaults.py create mode 100644 src/server/utils/service_errors.py create mode 100644 tests/unit/test_storage_default_provider.py diff --git a/.env.example b/.env.example index 21bf35060..f5a76bd5e 100644 --- a/.env.example +++ b/.env.example @@ -4,10 +4,11 @@ # These are the ONLY variables you need to start PowerMem. Everything else has # a safe default, so the system runs out of the box: # -# - Database -> OceanBase provider with no host configured, which boots -# embedded seekdb on disk at ./seekdb_data (same engine, -# same SQL surface, no separate server). Set OCEANBASE_HOST -# in `.env.example.full` to point at a remote cluster. +# - Database -> platform-aware default. Linux with embedded SeekDB available +# uses OceanBase/SeekDB on disk; other zero-config platforms +# use SQLite at ./data/powermem_dev.db for basic CRUD/search. +# Use OceanBase/SeekDB for Graph Store, sub_stores, sparse +# vectors, and SkillStore. # - Embedder -> built-in local all-MiniLM-L6-v2 (no API key required; # model auto-downloads to ~/.cache on first use) # - Reranker / graph store / telemetry / audit -> sensible defaults / off diff --git a/.env.example.full b/.env.example.full index 7fa6a0de3..31c28a80f 100644 --- a/.env.example.full +++ b/.env.example.full @@ -15,9 +15,10 @@ # cp .env.example.full .env # # Notes on defaults: -# - Database: DATABASE_PROVIDER=oceanbase with OCEANBASE_HOST left empty, -# which boots embedded seekdb on disk (same engine, no separate server). -# Set OCEANBASE_HOST to point at a real OceanBase cluster instead. +# - Database: platform-aware. Linux with embedded SeekDB available defaults +# to OceanBase/SeekDB on disk; other zero-config platforms default to +# SQLite for basic local memory CRUD/search. Use OceanBase/SeekDB for the +# full feature stack. # - Embedder: built-in `all-MiniLM-L6-v2` (384 dims) running locally with no # API key. Setting EMBEDDING_PROVIDER below switches to a cloud / self- # hosted embedder instead. @@ -36,14 +37,14 @@ # ============================================================================= # # DATABASE_PROVIDER — which storage engine PowerMem talks to. -# Recommended: oceanbase (the OceanBase backend covers both deployment -# shapes: leave OCEANBASE_HOST empty for embedded -# seekdb on disk — zero ops, no separate server — +# Recommended: oceanbase (full capability stack; leave OCEANBASE_HOST empty +# for embedded SeekDB on supported Linux installs, # or set OCEANBASE_HOST to point at a remote # OceanBase cluster) -# Other options: sqlite (smallest footprint, dev-only), +# Leave unset to use the platform-aware default. +# Other options: sqlite (smallest footprint, basic local CRUD/search), # postgres / pgvector (if your stack already runs PostgreSQL) -DATABASE_PROVIDER=oceanbase +# DATABASE_PROVIDER=oceanbase # ----------------------------------------------------------------------------- # OceanBase — used when DATABASE_PROVIDER=oceanbase. The same provider covers diff --git a/apps/claude-code-plugin/SETUP.md b/apps/claude-code-plugin/SETUP.md index cf13df7f9..d511dd9e3 100644 --- a/apps/claude-code-plugin/SETUP.md +++ b/apps/claude-code-plugin/SETUP.md @@ -245,8 +245,9 @@ writing. Never silently patch `.env`.** with LLM_PROVIDER / LLM_API_KEY or LLM_AUTH_TOKEN / LLM_MODEL set to real values (not placeholders like `your_api_key_here`), REUSE it — skip directly to step 3a/3b. Only collect - what is missing. Use zero-config defaults for everything else (storage = embedded - seekdb, embedder = local all-MiniLM-L6-v2) unless I say otherwise. + what is missing. Use zero-config defaults for everything else (storage = + platform-aware: embedded SeekDB on supported Linux installs, otherwise SQLite + basic mode; embedder = local all-MiniLM-L6-v2) unless I say otherwise. **2a. Auto-detect or manual?** Use AskUserQuestion (single-select): @@ -351,7 +352,9 @@ writing. Never silently patch `.env`.** ⚠️ All three extras are required: `[server]` adds fastapi/uvicorn; `[mcp]` adds fastmcp, which is checked at import time and calls sys.exit(1) if missing — this kills the HTTP server before it can start even in HTTP-only mode; - `[seekdb]` adds the embedded seekdb storage backend (default). + `[seekdb]` adds the embedded seekdb storage backend. Without it, platforms + where embedded SeekDB is unavailable default to SQLite basic mode; use + OceanBase/SeekDB for the full capability stack. - Immediately after `uv pip install`, detect which Python interpreter was used. Read the shebang from the freshly-installed `powermem-server` entry point — this is the only reliable way to guarantee that the model-download script, the uv call diff --git a/dashboard/src/components/system-health-card.tsx b/dashboard/src/components/system-health-card.tsx index 51534f98e..392684045 100644 --- a/dashboard/src/components/system-health-card.tsx +++ b/dashboard/src/components/system-health-card.tsx @@ -94,6 +94,12 @@ export function SystemHealthCard({ status }: SystemHealthCardProps) { const systemStatus = getStatusDisplay(status.status); const StatusIcon = systemStatus.icon; + const storageCapabilities = status.storage_capabilities; + const storageLimitations = storageCapabilities?.limitations ?? []; + const showStorageWarning = + status.storage_type?.toLowerCase() === "sqlite" || + (storageCapabilities?.provider === "sqlite" && + storageCapabilities.full_stack_available === false); return ( @@ -143,6 +149,25 @@ export function SystemHealthCard({ status }: SystemHealthCardProps) { )} + {showStorageWarning && ( +
+
+ +
+

+ {t("dashboard.systemHealth.sqliteWarningTitle")} +

+

{t("dashboard.systemHealth.sqliteWarningDescription")}

+ {storageLimitations.length > 0 && ( +

+ {storageLimitations.join("; ")} +

+ )} +
+
+
+ )} + {/* Dependencies Table */} {status.dependencies && Object.keys(status.dependencies).length > 0 && ( diff --git a/dashboard/src/i18n/locales/en.json b/dashboard/src/i18n/locales/en.json index 6d2dbe4c2..882814f0d 100644 --- a/dashboard/src/i18n/locales/en.json +++ b/dashboard/src/i18n/locales/en.json @@ -47,7 +47,9 @@ "statusDown": "Down", "latency": "latency", "noStatus": "No status available", - "loading": "Loading system status..." + "loading": "Loading system status...", + "sqliteWarningTitle": "SQLite basic mode", + "sqliteWarningDescription": "PowerMem is running on SQLite. Basic memory CRUD and search are available, but the full capability stack requires OceanBase or embedded SeekDB." }, "charts": { "growthTrend": "Growth Trend", diff --git a/dashboard/src/i18n/locales/zh.json b/dashboard/src/i18n/locales/zh.json index a3dafbc2c..d0dced07d 100644 --- a/dashboard/src/i18n/locales/zh.json +++ b/dashboard/src/i18n/locales/zh.json @@ -47,7 +47,9 @@ "statusDown": "故障", "latency": "延迟", "noStatus": "暂无状态信息", - "loading": "加载系统状态中..." + "loading": "加载系统状态中...", + "sqliteWarningTitle": "SQLite 基础模式", + "sqliteWarningDescription": "PowerMem 当前使用 SQLite。基础记忆 CRUD 与搜索可用,完整能力栈需要切换到 OceanBase 或嵌入式 SeekDB。" }, "charts": { "growthTrend": "增长趋势", diff --git a/dashboard/src/types/api.ts b/dashboard/src/types/api.ts index 17d08240e..85b42c04d 100644 --- a/dashboard/src/types/api.ts +++ b/dashboard/src/types/api.ts @@ -15,6 +15,15 @@ export interface SystemStatus { version: string; storage_type?: string; llm_provider?: string; + memory_service_ready?: boolean; + startup_error?: string; + storage_capabilities?: { + provider?: string; + defaulted: boolean; + full_stack_available: boolean; + limitations: string[]; + recommendation?: string; + }; uptime_seconds: number; started_at: string; dependencies: Record; diff --git a/docs/guides/0003-configuration.md b/docs/guides/0003-configuration.md index 9a1f290cc..e187fd1b9 100644 --- a/docs/guides/0003-configuration.md +++ b/docs/guides/0003-configuration.md @@ -91,17 +91,17 @@ memory = Memory(config=config) ## 1. Database Configuration (Required) -PowerMem requires a database provider to store memories and vectors. Choose one of the supported providers: SQLite (development), OceanBase (production), or PostgreSQL. +PowerMem requires a database provider to store memories and vectors. The zero-config default is platform-aware: Linux with embedded SeekDB available uses the OceanBase provider in embedded mode; other platforms fall back to SQLite for basic local memory CRUD/search. Choose OceanBase/SeekDB for the full capability stack, or PostgreSQL/pgvector when your deployment already runs PostgreSQL. ### Common Database Settings | Configuration | Type | Required | Default | Description | |--------------|------|----------|---------|-------------| -| `DATABASE_PROVIDER` | string | Yes | `sqlite` | Database provider to use. Options: `sqlite`, `oceanbase`, `postgres` | +| `DATABASE_PROVIDER` | string | No | platform-aware | Database provider to use. Options: `sqlite`, `oceanbase`, `postgres` | ### SQLite Configuration -SQLite is the default database provider, recommended for development and single-user applications. +SQLite is the fallback default when embedded SeekDB is unavailable. It is recommended for development and single-user basic memory CRUD/search. Graph Store, sub_stores, sparse vector search, and SkillStore require OceanBase or embedded SeekDB. | Configuration | Type | Required | Default | Description | |--------------|------|----------|---------|-------------| @@ -147,15 +147,15 @@ config = { ### OceanBase Configuration -OceanBase is recommended for production deployments and enterprise applications with high-scale requirements. +OceanBase is recommended for production deployments and for the full PowerMem capability stack. With `OCEANBASE_HOST` empty, the OceanBase provider uses embedded SeekDB when the native dependency is available. Set `OCEANBASE_HOST` for a remote OceanBase cluster. | Configuration | Type | Required | Default | Description | |--------------|------|----------|---------|-------------| -| `OCEANBASE_HOST` | string | Yes* | `127.0.0.1` | OceanBase server hostname or IP address. Required when `DATABASE_PROVIDER=oceanbase` | -| `OCEANBASE_PORT` | integer | Yes* | `2881` | OceanBase server port. Required when `DATABASE_PROVIDER=oceanbase` | -| `OCEANBASE_USER` | string | Yes* | `root` | Database username. Required when `DATABASE_PROVIDER=oceanbase` | -| `OCEANBASE_PASSWORD` | string | Yes* | - | Database password. Required when `DATABASE_PROVIDER=oceanbase` | -| `OCEANBASE_DATABASE` | string | Yes* | `powermem` | Database name. Required when `DATABASE_PROVIDER=oceanbase` | +| `OCEANBASE_HOST` | string | No* | empty | OceanBase server hostname or IP address. Empty means embedded SeekDB mode; remote mode requires a host | +| `OCEANBASE_PORT` | integer | Yes* | `2881` | OceanBase server port. Required in remote mode | +| `OCEANBASE_USER` | string | Yes* | `root@test` | Database username. Required in remote mode | +| `OCEANBASE_PASSWORD` | string | No | - | Database password. Required if your remote cluster needs one | +| `OCEANBASE_DATABASE` | string | Yes* | `test` | Database name | | `OCEANBASE_COLLECTION` | string | No | `memories` | Collection/table name for storing memories | | `OCEANBASE_INDEX_TYPE` | string | No | `IVF_FLAT` | Vector index type. Options: `IVF_FLAT`, `HNSW`, etc. | | `OCEANBASE_VECTOR_METRIC_TYPE` | string | No | `cosine` | Vector similarity metric. Options: `cosine`, `euclidean`, `dot_product` | diff --git a/src/powermem/cli/commands/config.py b/src/powermem/cli/commands/config.py index 9a953e002..d99919219 100644 --- a/src/powermem/cli/commands/config.py +++ b/src/powermem/cli/commands/config.py @@ -23,6 +23,12 @@ print_warning, print_info, ) +from powermem.platform_defaults import ( + default_database_provider, + embedded_seekdb_available, + embedded_seekdb_unavailable_message, + sqlite_capability_warning, +) logger = logging.getLogger(__name__) from ..utils.envfile import read_env_file, update_env_file @@ -295,11 +301,19 @@ def _validate_loaded_config(config: Dict[str, Any], strict: bool) -> Dict[str, A provider = vector_store_config.get("provider", "") inner_config = vector_store_config.get("config", {}) if provider == "oceanbase": - required_fields = ["host", "port", "user", "db_name"] conn_args = inner_config.get("connection_args", {}) - for field in required_fields: - if not conn_args.get(field): - errors.append(f"OceanBase connection missing: {field}") + host = conn_args.get("host") or inner_config.get("host") + if host: + required_fields = ["port", "user", "db_name"] + for field in required_fields: + if not (conn_args.get(field) or inner_config.get(field)): + errors.append(f"OceanBase connection missing: {field}") + elif not embedded_seekdb_available(): + errors.append(embedded_seekdb_unavailable_message()) + elif provider == "sqlite": + warning = sqlite_capability_warning(provider, defaulted=not bool(os.environ.get("DATABASE_PROVIDER"))) + if warning: + warnings.append(warning) elif provider in ("postgres", "pgvector"): required_fields = ["host", "port", "user", "dbname"] for field in required_fields: @@ -795,7 +809,7 @@ def _discover_env_example(start_dir: Path, max_parent_levels: int = 8) -> Option def _wizard_database(existing: Dict[str, str]) -> Dict[str, str]: updates: Dict[str, str] = {} - provider_default = existing.get("DATABASE_PROVIDER") or "sqlite" + provider_default = existing.get("DATABASE_PROVIDER") or default_database_provider() provider = click.prompt( "Database provider", type=click.Choice(["oceanbase", "postgres", "sqlite"], case_sensitive=False), @@ -909,7 +923,7 @@ def _wizard_database_quickstart(existing: Dict[str, str]) -> Dict[str, str]: This intentionally avoids optional knobs (WAL/timeout/collection/etc.). """ updates: Dict[str, str] = {} - provider_default = existing.get("DATABASE_PROVIDER") or "sqlite" + provider_default = existing.get("DATABASE_PROVIDER") or default_database_provider() provider = click.prompt( "Database provider", type=click.Choice(["sqlite", "oceanbase", "postgres"], case_sensitive=False), @@ -1278,7 +1292,7 @@ def init_cmd(ctx: CLIContext, env_file: Optional[str], dry_run: bool, test: bool emb_updates = _wizard_embedder_quickstart(existing, llm_updates=llm_updates) updates.update(emb_updates) - db_provider = updates.get("DATABASE_PROVIDER") or existing.get("DATABASE_PROVIDER") or "sqlite" + db_provider = updates.get("DATABASE_PROVIDER") or existing.get("DATABASE_PROVIDER") or default_database_provider() dims = emb_updates.get("EMBEDDING_DIMS") if dims: updates.update(_sync_vector_dims_quickstart(db_provider=db_provider, dims=dims)) @@ -1293,7 +1307,7 @@ def init_cmd(ctx: CLIContext, env_file: Optional[str], dry_run: bool, test: bool emb_updates = _wizard_embedder(existing, llm_updates=llm_updates) updates.update(emb_updates) - db_provider = updates.get("DATABASE_PROVIDER") or existing.get("DATABASE_PROVIDER") or "sqlite" + db_provider = updates.get("DATABASE_PROVIDER") or existing.get("DATABASE_PROVIDER") or default_database_provider() dims = emb_updates.get("EMBEDDING_DIMS") if dims: updates.update(_maybe_sync_vector_dims(db_provider=db_provider, dims=dims)) diff --git a/src/powermem/config_loader.py b/src/powermem/config_loader.py index d087eff8d..5d06d08e0 100644 --- a/src/powermem/config_loader.py +++ b/src/powermem/config_loader.py @@ -7,6 +7,7 @@ import os import warnings +import logging from typing import Any, Dict, Optional from pydantic import AliasChoices, BaseModel, ConfigDict, Field @@ -17,10 +18,18 @@ from powermem.integrations.embeddings.config.sparse_base import BaseSparseEmbedderConfig from powermem.integrations.llm.config.base import BaseLLMConfig from powermem.integrations.llm.config.noop import NoopConfig # noqa: F401 - register noop provider +from powermem.platform_defaults import ( + choose_default_database_provider, + default_database_provider, + sqlite_capability_warning, +) from powermem.settings import _DEFAULT_ENV_FILE, settings_config from powermem.utils.utils import detect_system_timezone +logger = logging.getLogger(__name__) + + def _load_dotenv_if_available() -> None: """ Load env files into os.environ before BaseSettings / Memory read configuration. @@ -125,7 +134,7 @@ class DatabaseSettings(_BasePowermemSettings): model_config = settings_config() provider: str = Field( - default="oceanbase", + default_factory=default_database_provider, validation_alias=AliasChoices("DATABASE_PROVIDER"), ) @@ -139,11 +148,16 @@ def to_config(self) -> Dict[str, Any]: from powermem.storage.config.base import BaseVectorStoreConfig db_provider = self.provider.lower() + decision = choose_default_database_provider() # Handle postgres alias if db_provider == "postgres": db_provider = "pgvector" + warning = sqlite_capability_warning(db_provider, defaulted=decision.defaulted) + if warning and decision.defaulted: + logger.warning("Defaulting storage provider to SQLite: %s", warning) + # 1. Get provider config class from registry config_cls = ( BaseVectorStoreConfig.get_provider_config_cls(db_provider) @@ -729,7 +743,7 @@ def load_config_from_env() -> Dict[str, Any]: class CreateConfigOptions(BaseModel): model_config = ConfigDict(extra="forbid") - database_provider: str = "oceanbase" + database_provider: str = Field(default_factory=default_database_provider) llm_provider: str = "qwen" embedding_provider: str = "qwen" database_config: Dict[str, Any] = Field(default_factory=dict) @@ -749,7 +763,7 @@ class CreateConfigOptions(BaseModel): def create_config( - database_provider: str = "oceanbase", + database_provider: Optional[str] = None, llm_provider: str = "qwen", embedding_provider: str = "qwen", database_config: Optional[Dict[str, Any]] = None, @@ -812,7 +826,7 @@ def create_config( stacklevel=2, ) options = CreateConfigOptions( - database_provider=database_provider, + database_provider=database_provider or default_database_provider(), llm_provider=llm_provider, embedding_provider=embedding_provider, database_config=database_config or {}, diff --git a/src/powermem/configs.py b/src/powermem/configs.py index 0c1252212..cb622f72d 100644 --- a/src/powermem/configs.py +++ b/src/powermem/configs.py @@ -23,9 +23,21 @@ OceanBaseConfig, OceanBaseGraphConfig, # noqa: F401 — keeps OceanBase graph provider registered ) +from powermem.platform_defaults import default_database_provider from powermem.integrations.rerank.config.base import BaseRerankConfig +def _default_vector_store_config() -> BaseVectorStoreConfig: + provider = default_database_provider() + if provider == "sqlite": + return SQLiteConfig() + if provider in ("postgres", "pgvector"): + from powermem.storage.config.pgvector import PGVectorConfig + + return PGVectorConfig() + return OceanBaseConfig() + + class IntelligentMemoryConfig(BaseModel): """Configuration for intelligent memory management with Ebbinghaus algorithm.""" @@ -251,13 +263,13 @@ class MemoryConfig(BaseModel): vector_store: BaseVectorStoreConfig = Field( description=( - "Configuration for the vector store. Defaults to the OceanBase " - "provider with an empty host, which boots embedded seekdb on " - "disk (no separate server) so PowerMem starts with zero ops; " - "set OCEANBASE_HOST to point at a remote OceanBase cluster, or " - "switch the provider to sqlite / postgres." + "Configuration for the vector store. Defaults are platform-aware: " + "explicit DATABASE_PROVIDER wins; OCEANBASE_HOST selects remote " + "OceanBase; Linux with embedded SeekDB capability uses OceanBase " + "embedded mode; otherwise PowerMem falls back to SQLite for basic " + "local memory CRUD/search." ), - default_factory=OceanBaseConfig, + default_factory=_default_vector_store_config, ) llm: BaseLLMConfig = Field( description="Configuration for the language model", diff --git a/src/powermem/core/async_memory.py b/src/powermem/core/async_memory.py index 176d19539..92143c4a7 100644 --- a/src/powermem/core/async_memory.py +++ b/src/powermem/core/async_memory.py @@ -16,6 +16,7 @@ from .base import MemoryBase from ..configs import MemoryConfig +from ..platform_defaults import default_database_provider from ..storage.factory import VectorStoreFactory, GraphStoreFactory from ..storage.adapter import StorageAdapter, SubStorageAdapter from ..intelligence.manager import IntelligenceManager @@ -83,13 +84,13 @@ def _auto_convert_config(config: Dict[str, Any]) -> Dict[str, Any]: if "database" in config: db_config = config["database"] converted["vector_store"] = { - "provider": db_config.get("provider", "oceanbase"), + "provider": db_config.get("provider", default_database_provider()), "config": db_config.get("config", {}) } converted.pop("database", None) elif "vector_store" not in converted: converted["vector_store"] = { - "provider": "oceanbase", + "provider": default_database_provider(), "config": {} } @@ -145,7 +146,7 @@ def __init__( self.agent_id = agent_id # Extract providers from config with fallbacks - self.storage_type = storage_type or self._get_provider('vector_store', 'oceanbase') + self.storage_type = storage_type or self._get_provider('vector_store', default_database_provider()) self.llm_provider = llm_provider or self._get_provider('llm', 'mock') self.embedding_provider = embedding_provider or self._get_provider('embedder', 'mock') diff --git a/src/powermem/core/memory.py b/src/powermem/core/memory.py index 28288e5dc..b5b4c8f90 100644 --- a/src/powermem/core/memory.py +++ b/src/powermem/core/memory.py @@ -17,6 +17,7 @@ from .base import MemoryBase from ..configs import MemoryConfig +from ..platform_defaults import default_database_provider from ..integrations.embeddings.config.sparse_base import BaseSparseEmbedderConfig from ..storage.factory import VectorStoreFactory, GraphStoreFactory from ..storage.adapter import StorageAdapter, SubStorageAdapter @@ -481,13 +482,13 @@ def _auto_convert_config(config: Dict[str, Any]) -> Dict[str, Any]: if "database" in config: db_config = config["database"] converted["vector_store"] = { - "provider": db_config.get("provider", "oceanbase"), + "provider": db_config.get("provider", default_database_provider()), "config": db_config.get("config", {}) } converted.pop("database", None) elif "vector_store" not in converted: converted["vector_store"] = { - "provider": "oceanbase", + "provider": default_database_provider(), "config": {} } @@ -584,7 +585,7 @@ def __init__( logger.debug(f"Timezone set from config: {timezone_config}") # Extract providers from config with fallbacks - self.storage_type = storage_type or self._get_provider('vector_store', 'oceanbase') + self.storage_type = storage_type or self._get_provider('vector_store', default_database_provider()) self.llm_provider = llm_provider or self._get_provider('llm', 'mock') self.embedding_provider = embedding_provider or self._get_provider('embedder', 'mock') diff --git a/src/powermem/core/setup.py b/src/powermem/core/setup.py index 46b5c2f28..9e86c68f4 100644 --- a/src/powermem/core/setup.py +++ b/src/powermem/core/setup.py @@ -14,6 +14,7 @@ from pydantic import Field, field_validator from pydantic_settings import BaseSettings +from ..platform_defaults import default_database_provider from ..settings import settings_config logger = logging.getLogger(__name__) @@ -138,12 +139,12 @@ def _convert_legacy_to_mem_config(config: Dict[str, Any]) -> Dict[str, Any]: if "database" in config: db_config = config["database"] converted["vector_store"] = { - "provider": db_config.get("provider", "oceanbase"), + "provider": db_config.get("provider", default_database_provider()), "config": db_config.get("config", {}) } else: converted["vector_store"] = { - "provider": "oceanbase", + "provider": default_database_provider(), "config": {} } diff --git a/src/powermem/integrations/llm/factory.py b/src/powermem/integrations/llm/factory.py index fb76a0544..a9a2359c0 100644 --- a/src/powermem/integrations/llm/factory.py +++ b/src/powermem/integrations/llm/factory.py @@ -49,7 +49,7 @@ def create(cls, provider_name: str, config: Optional[Union[BaseLLMConfig, Dict]] # 1. Get class_path from registry class_path = BaseLLMConfig.get_provider_class_path(provider_name) if not class_path: - raise ValueError(f"Unsupported Llm provider: {provider_name}") + raise ValueError(f"Unsupported LLM provider: {provider_name}") # 2. Get config_cls from registry config_cls = BaseLLMConfig.get_provider_config_cls(provider_name) or BaseLLMConfig diff --git a/src/powermem/platform_defaults.py b/src/powermem/platform_defaults.py new file mode 100644 index 000000000..718ceef7d --- /dev/null +++ b/src/powermem/platform_defaults.py @@ -0,0 +1,137 @@ +"""Platform-aware defaults for local PowerMem startup.""" + +from __future__ import annotations + +import importlib.util +import os +import sys +from dataclasses import dataclass +from typing import Any, Dict + + +SQLITE_LIMITATIONS = [ + "Graph Store is not available", + "sub_stores routing is not available", + "Sparse vector search is not available", + "SkillStore requires OceanBase or embedded SeekDB", +] + +SQLITE_RECOMMENDATION = ( + "SQLite is suitable for local development and basic memory CRUD/search. " + 'For the full PowerMem capability stack, use embedded SeekDB on Linux with ' + 'pip install "powermem[seekdb]", or configure DATABASE_PROVIDER=oceanbase ' + "with OCEANBASE_HOST for a remote OceanBase cluster." +) + + +@dataclass(frozen=True) +class StorageDefaultDecision: + provider: str + reason: str + defaulted: bool + embedded_seekdb_available: bool + + +def database_provider_explicitly_configured() -> bool: + return bool(os.environ.get("DATABASE_PROVIDER")) + + +def oceanbase_remote_configured() -> bool: + return bool(os.environ.get("OCEANBASE_HOST")) + + +def embedded_seekdb_available() -> bool: + """Return whether embedded SeekDB can be used on this platform. + + This is intentionally a light capability probe. It avoids constructing an + ObVecClient so configuration loading never creates data files or starts the + embedded engine. + """ + if not sys.platform.startswith("linux"): + return False + return ( + importlib.util.find_spec("pyobvector") is not None + and importlib.util.find_spec("pyseekdb") is not None + and importlib.util.find_spec("pylibseekdb") is not None + ) + + +def choose_default_database_provider() -> StorageDefaultDecision: + configured_provider = os.environ.get("DATABASE_PROVIDER") + if configured_provider: + return StorageDefaultDecision( + provider=configured_provider.lower(), + reason="DATABASE_PROVIDER is explicitly configured", + defaulted=False, + embedded_seekdb_available=embedded_seekdb_available(), + ) + + if oceanbase_remote_configured(): + return StorageDefaultDecision( + provider="oceanbase", + reason="OCEANBASE_HOST is configured, using remote OceanBase", + defaulted=True, + embedded_seekdb_available=embedded_seekdb_available(), + ) + + seekdb_available = embedded_seekdb_available() + if seekdb_available: + return StorageDefaultDecision( + provider="oceanbase", + reason="Linux embedded SeekDB capability detected", + defaulted=True, + embedded_seekdb_available=True, + ) + + return StorageDefaultDecision( + provider="sqlite", + reason="embedded SeekDB is not available on this platform, using SQLite", + defaulted=True, + embedded_seekdb_available=False, + ) + + +def default_database_provider() -> str: + return choose_default_database_provider().provider + + +def storage_capabilities(provider: str, defaulted: bool | None = None) -> Dict[str, Any]: + normalized = (provider or "").lower() + if defaulted is None: + defaulted = not database_provider_explicitly_configured() + + if normalized == "sqlite": + return { + "provider": "sqlite", + "defaulted": bool(defaulted), + "full_stack_available": False, + "limitations": list(SQLITE_LIMITATIONS), + "recommendation": SQLITE_RECOMMENDATION, + } + + return { + "provider": normalized or None, + "defaulted": bool(defaulted), + "full_stack_available": normalized == "oceanbase", + "limitations": [], + "recommendation": None, + } + + +def sqlite_capability_warning(provider: str, defaulted: bool | None = None) -> str | None: + capabilities = storage_capabilities(provider, defaulted=defaulted) + if capabilities.get("provider") != "sqlite": + return None + limitations = "; ".join(capabilities["limitations"]) + return f"{capabilities['recommendation']} Limitations in SQLite mode: {limitations}." + + +def embedded_seekdb_unavailable_message() -> str: + return ( + "Embedded SeekDB is not available on this platform or installation. " + f"platform={sys.platform}, pyobvector={importlib.util.find_spec('pyobvector') is not None}, " + f"pyseekdb={importlib.util.find_spec('pyseekdb') is not None}, " + f"pylibseekdb={importlib.util.find_spec('pylibseekdb') is not None}. " + 'Use DATABASE_PROVIDER=sqlite for local basic storage, install "powermem[seekdb]" ' + "on Linux for embedded SeekDB, or set OCEANBASE_HOST for a remote OceanBase cluster." + ) diff --git a/src/powermem/storage/oceanbase/oceanbase.py b/src/powermem/storage/oceanbase/oceanbase.py index 92b80fd42..21f93b836 100644 --- a/src/powermem/storage/oceanbase/oceanbase.py +++ b/src/powermem/storage/oceanbase/oceanbase.py @@ -11,6 +11,10 @@ from typing import Any, Dict, List, Optional from powermem.storage.base import VectorStoreBase, OutputData +from powermem.platform_defaults import ( + embedded_seekdb_available, + embedded_seekdb_unavailable_message, +) from powermem.utils.utils import serialize_datetime, generate_snowflake_id from powermem.utils.oceanbase_util import OceanBaseUtil @@ -217,6 +221,8 @@ def _create_client(self, **kwargs): **kwargs, ) else: + if not embedded_seekdb_available(): + raise RuntimeError(embedded_seekdb_unavailable_message()) ob_path = self.connection_args.get("ob_path", "./seekdb_data") OceanBaseUtil.ensure_embedded_database_exists(ob_path, db_name) self.obvector = ObVecClient(path=ob_path, db_name=db_name) @@ -2450,4 +2456,4 @@ def execute_sql(self, sql: str, params: Optional[Dict[str, Any]] = None) -> List except Exception as e: logger.error(f"Failed to execute SQL: {e}") logger.debug(f"SQL statement: {sql}") - raise \ No newline at end of file + raise diff --git a/src/server/api/v1/agents.py b/src/server/api/v1/agents.py index f4d3ae4bf..9554b5f74 100644 --- a/src/server/api/v1/agents.py +++ b/src/server/api/v1/agents.py @@ -12,6 +12,7 @@ from ...middleware.auth import verify_api_key from ...middleware.rate_limit import limiter, get_rate_limit_string from ...utils.converters import memory_dict_to_response +from ...utils.service_errors import service_unavailable_message router = APIRouter(prefix="/agents", tags=["agents"]) @@ -23,7 +24,7 @@ def get_agent_service(request: Request) -> AgentService: from ...models.errors import ErrorCode, APIError raise APIError( code=ErrorCode.INTERNAL_ERROR, - message="Agent service unavailable: storage backend initialization failed", + message=service_unavailable_message(request, "Agent"), status_code=503, ) return service diff --git a/src/server/api/v1/memories.py b/src/server/api/v1/memories.py index fe2568821..ca946c211 100644 --- a/src/server/api/v1/memories.py +++ b/src/server/api/v1/memories.py @@ -25,6 +25,7 @@ from ...middleware.auth import verify_api_key from ...middleware.rate_limit import limiter, get_rate_limit_string from ...utils.converters import memory_dict_to_response +from ...utils.service_errors import service_unavailable_message logger = logging.getLogger("server") @@ -55,7 +56,7 @@ def get_memory_service(request: Request) -> MemoryService: from ...models.errors import ErrorCode, APIError raise APIError( code=ErrorCode.INTERNAL_ERROR, - message="Memory service unavailable: storage backend initialization failed", + message=service_unavailable_message(request, "Memory"), status_code=503, ) return service diff --git a/src/server/api/v1/search.py b/src/server/api/v1/search.py index 3803016fa..eecc8b4e5 100644 --- a/src/server/api/v1/search.py +++ b/src/server/api/v1/search.py @@ -14,6 +14,7 @@ from ...middleware.auth import verify_api_key from ...middleware.rate_limit import limiter, get_rate_limit_string from ...utils.converters import search_result_to_response +from ...utils.service_errors import service_unavailable_message from .memories import parse_time_range_cutoff router = APIRouter(prefix="/memories", tags=["search"]) @@ -69,7 +70,7 @@ def get_search_service(request: Request) -> SearchService: from ...models.errors import ErrorCode, APIError raise APIError( code=ErrorCode.INTERNAL_ERROR, - message="Search service unavailable: storage backend initialization failed", + message=service_unavailable_message(request, "Search"), status_code=503, ) return service diff --git a/src/server/api/v1/system.py b/src/server/api/v1/system.py index f4fb03d68..0919b7856 100644 --- a/src/server/api/v1/system.py +++ b/src/server/api/v1/system.py @@ -7,7 +7,12 @@ from typing import Optional from datetime import datetime, timezone -from ...models.response import APIResponse, HealthResponse, StatusResponse +from ...models.response import ( + APIResponse, + DependencyStatus, + HealthResponse, + StatusResponse, +) from ...middleware.auth import verify_api_key from ...middleware.rate_limit import limiter, get_rate_limit_string from ...config import config @@ -28,14 +33,21 @@ summary="Health check", description="Check if the API server is healthy (public endpoint, no authentication required)", ) -async def health_check(): +async def health_check(request: Request): """Health check endpoint""" - health = HealthResponse(status="healthy") + ready = bool(getattr(request.app.state, "service_ready", False)) + startup_error = getattr(request.app.state, "service_startup_error", None) + health = HealthResponse( + status="healthy" if ready else "degraded", + memory_service_ready=ready, + storage_type=getattr(request.app.state, "storage_type", None), + startup_error=(str(startup_error)[:200] if startup_error else None), + ) return APIResponse( success=True, data=health.model_dump(mode='json'), - message="Service is healthy", + message="Service is healthy" if ready else "HTTP server is running but memory service is unavailable", ) @@ -55,20 +67,23 @@ async def get_status( # Get PowerMem config powermem_config = auto_config() - storage_type = None + storage_type = getattr(request.app.state, "storage_type", None) llm_provider = None + storage_capabilities = getattr(request.app.state, "storage_capabilities", None) + memory_service_ready = bool(getattr(request.app.state, "service_ready", False)) + startup_error = getattr(request.app.state, "service_startup_error", None) if isinstance(powermem_config, dict): # Extract from dict config vector_store = powermem_config.get("vector_store") or powermem_config.get("database", {}) - storage_type = vector_store.get("provider") if isinstance(vector_store, dict) else None + storage_type = storage_type or (vector_store.get("provider") if isinstance(vector_store, dict) else None) llm = powermem_config.get("llm", {}) llm_provider = llm.get("provider") if isinstance(llm, dict) else None else: # Extract from config object if hasattr(powermem_config, "vector_store") and powermem_config.vector_store: - storage_type = powermem_config.vector_store.provider + storage_type = storage_type or powermem_config.vector_store.provider if hasattr(powermem_config, "llm") and powermem_config.llm: llm_provider = powermem_config.llm.provider @@ -78,6 +93,17 @@ async def get_status( # Check dependencies dependencies = await check_all_dependencies() + dependencies["memory_service"] = DependencyStatus( + name="memory_service", + status="healthy" if memory_service_ready else "unavailable", + error_message=(str(startup_error)[:200] if startup_error else None), + last_checked=datetime.utcnow(), + ) + + if storage_capabilities is None: + from powermem.platform_defaults import storage_capabilities as build_storage_capabilities + + storage_capabilities = build_storage_capabilities(storage_type or "") # Determine overall system status based on dependencies system_status = "operational" @@ -100,6 +126,9 @@ async def get_status( version=powermem_version, storage_type=storage_type, llm_provider=llm_provider, + memory_service_ready=memory_service_ready, + startup_error=(str(startup_error)[:200] if startup_error else None), + storage_capabilities=storage_capabilities, uptime_seconds=uptime_seconds, started_at=SERVER_START_TIME, dependencies=dependencies_dict, @@ -120,6 +149,9 @@ async def get_status( version=powermem_version, storage_type=None, llm_provider=None, + memory_service_ready=bool(getattr(request.app.state, "service_ready", False)), + startup_error=str(getattr(request.app.state, "service_startup_error", "") or e)[:200], + storage_capabilities=getattr(request.app.state, "storage_capabilities", None), uptime_seconds=uptime_seconds, started_at=SERVER_START_TIME, dependencies={}, diff --git a/src/server/api/v1/users.py b/src/server/api/v1/users.py index 9f44180a9..232d9ff03 100644 --- a/src/server/api/v1/users.py +++ b/src/server/api/v1/users.py @@ -12,6 +12,7 @@ from ...middleware.auth import verify_api_key from ...middleware.rate_limit import limiter, get_rate_limit_string from ...utils.converters import user_profile_to_response, memory_dict_to_response +from ...utils.service_errors import service_unavailable_message router = APIRouter(prefix="/users", tags=["users"]) @@ -23,7 +24,7 @@ def get_user_service(request: Request) -> UserService: from ...models.errors import ErrorCode, APIError raise APIError( code=ErrorCode.INTERNAL_ERROR, - message="User service unavailable: storage backend initialization failed", + message=service_unavailable_message(request, "User"), status_code=503, ) return service diff --git a/src/server/main.py b/src/server/main.py index 7daa18dae..d266951d0 100644 --- a/src/server/main.py +++ b/src/server/main.py @@ -21,6 +21,7 @@ from .middleware.auth import verify_api_key import logging +import os from importlib.util import find_spec # Setup logging @@ -56,18 +57,39 @@ async def _service_lifespan(app: FastAPI): from .services.agent_service import AgentService logger.info("Initializing service singletons...") + app.state.memory_service = None + app.state.search_service = None + app.state.user_service = None + app.state.agent_service = None + app.state.service_ready = False + app.state.service_startup_error = None + app.state.storage_type = None + app.state.storage_capabilities = None try: + from powermem.platform_defaults import ( + sqlite_capability_warning, + storage_capabilities, + ) + app.state.memory_service = MemoryService() app.state.search_service = SearchService() app.state.user_service = UserService() app.state.agent_service = AgentService() + storage_type = getattr(app.state.memory_service.memory, "storage_type", None) + app.state.storage_type = storage_type + defaulted = not bool(os.environ.get("DATABASE_PROVIDER")) + app.state.storage_capabilities = storage_capabilities( + storage_type or "", + defaulted=defaulted, + ) + warning = sqlite_capability_warning(storage_type or "", defaulted=defaulted) + if warning: + logger.warning("PowerMem is running with SQLite storage. %s", warning) + app.state.service_ready = True logger.info("Service singletons initialized") except Exception as e: logger.error(f"Failed to initialize service singletons: {e}", exc_info=True) - app.state.memory_service = None - app.state.search_service = None - app.state.user_service = None - app.state.agent_service = None + app.state.service_startup_error = str(e) yield diff --git a/src/server/models/response.py b/src/server/models/response.py index d61336be7..5e8385121 100644 --- a/src/server/models/response.py +++ b/src/server/models/response.py @@ -154,6 +154,9 @@ class HealthResponse(BaseModel): """Response model for health check""" status: str = Field(..., description="Health status") + memory_service_ready: Optional[bool] = Field(None, description="Whether the memory service initialized successfully") + storage_type: Optional[str] = Field(None, description="Storage backend type") + startup_error: Optional[str] = Field(None, description="Startup error if service initialization failed") timestamp: datetime = Field(default_factory=get_current_datetime, description="Check timestamp") @field_serializer('timestamp') @@ -193,6 +196,16 @@ def serialize_datetime(self, value: datetime, _info): return utc_value.replace(tzinfo=None).isoformat() + "Z" +class StorageCapabilities(BaseModel): + """Storage capability summary for the active backend.""" + + provider: Optional[str] = Field(None, description="Storage provider") + defaulted: bool = Field(False, description="Whether provider was selected by default") + full_stack_available: bool = Field(False, description="Whether the full PowerMem feature stack is available") + limitations: List[str] = Field(default_factory=list, description="Known limitations for the active backend") + recommendation: Optional[str] = Field(None, description="Recommendation to unlock full capabilities") + + class StatusResponse(BaseModel): """Response model for system status""" @@ -200,6 +213,9 @@ class StatusResponse(BaseModel): version: str = Field(..., description="API version") storage_type: Optional[str] = Field(None, description="Storage backend type") llm_provider: Optional[str] = Field(None, description="LLM provider") + memory_service_ready: Optional[bool] = Field(None, description="Whether the memory service initialized successfully") + startup_error: Optional[str] = Field(None, description="Startup error if service initialization failed") + storage_capabilities: Optional[StorageCapabilities] = Field(None, description="Storage backend capability summary") uptime_seconds: Optional[float] = Field(None, description="Service uptime in seconds") started_at: Optional[datetime] = Field(None, description="Service start time") dependencies: Optional[Dict[str, Dict[str, Any]]] = Field(None, description="Dependency health status") diff --git a/src/server/utils/service_errors.py b/src/server/utils/service_errors.py new file mode 100644 index 000000000..decdf51f9 --- /dev/null +++ b/src/server/utils/service_errors.py @@ -0,0 +1,19 @@ +"""Helpers for service readiness errors.""" + +from __future__ import annotations + +from fastapi import Request + + +def service_unavailable_message(request: Request, service_name: str) -> str: + message = f"{service_name} service unavailable: storage backend initialization failed" + startup_error = getattr(request.app.state, "service_startup_error", None) + if startup_error: + message += f" ({str(startup_error)[:200]})" + message += ( + ". For local basic storage, set DATABASE_PROVIDER=sqlite. " + "For the full PowerMem capability stack, use embedded SeekDB on Linux " + 'with "powermem[seekdb]" or configure remote OceanBase with OCEANBASE_HOST.' + ) + return message + diff --git a/tests/unit/test_seekdb_default_storage.py b/tests/unit/test_seekdb_default_storage.py index 9cf4105ea..9912ae675 100644 --- a/tests/unit/test_seekdb_default_storage.py +++ b/tests/unit/test_seekdb_default_storage.py @@ -10,13 +10,22 @@ from __future__ import annotations -def test_memory_config_default_storage_is_oceanbase_in_embedded_mode(monkeypatch): - """`MemoryConfig()` with no env vars defaults to the OceanBase provider - with an empty host — i.e. embedded seekdb on disk. - """ +def test_memory_config_default_storage_is_oceanbase_when_embedded_seekdb_available(monkeypatch): + """Linux with embedded SeekDB capability keeps the OceanBase embedded default.""" monkeypatch.delenv("DATABASE_PROVIDER", raising=False) monkeypatch.delenv("OCEANBASE_HOST", raising=False) + import powermem.platform_defaults as platform_defaults + + monkeypatch.setattr(platform_defaults.sys, "platform", "linux") + monkeypatch.setattr( + platform_defaults.importlib.util, + "find_spec", + lambda name: object() + if name in {"pyobvector", "pyseekdb", "pylibseekdb"} + else None, + ) + from powermem.configs import MemoryConfig from powermem.storage.config.oceanbase import OceanBaseConfig @@ -27,12 +36,13 @@ def test_memory_config_default_storage_is_oceanbase_in_embedded_mode(monkeypatch assert cfg.vector_store.ob_path == "./seekdb_data" -def test_database_settings_default_provider_is_oceanbase(monkeypatch): +def test_database_settings_default_provider_matches_platform_helper(monkeypatch): monkeypatch.delenv("DATABASE_PROVIDER", raising=False) from powermem.config_loader import DatabaseSettings + from powermem.platform_defaults import default_database_provider - assert DatabaseSettings().provider == "oceanbase" + assert DatabaseSettings().provider == default_database_provider() def test_oceanbase_provider_picks_up_remote_host(monkeypatch): diff --git a/tests/unit/test_storage_default_provider.py b/tests/unit/test_storage_default_provider.py new file mode 100644 index 000000000..409f18cab --- /dev/null +++ b/tests/unit/test_storage_default_provider.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import powermem.platform_defaults as platform_defaults + + +def _mock_find_spec(monkeypatch, available: set[str]): + def fake_find_spec(name): + return object() if name in available else None + + monkeypatch.setattr(platform_defaults.importlib.util, "find_spec", fake_find_spec) + + +def test_windows_zero_config_defaults_to_sqlite(monkeypatch): + monkeypatch.delenv("DATABASE_PROVIDER", raising=False) + monkeypatch.delenv("OCEANBASE_HOST", raising=False) + monkeypatch.setattr(platform_defaults.sys, "platform", "win32") + _mock_find_spec(monkeypatch, {"pyobvector", "pyseekdb"}) + + decision = platform_defaults.choose_default_database_provider() + + assert decision.provider == "sqlite" + assert decision.defaulted is True + assert decision.embedded_seekdb_available is False + + +def test_linux_with_seekdb_defaults_to_oceanbase(monkeypatch): + monkeypatch.delenv("DATABASE_PROVIDER", raising=False) + monkeypatch.delenv("OCEANBASE_HOST", raising=False) + monkeypatch.setattr(platform_defaults.sys, "platform", "linux") + _mock_find_spec(monkeypatch, {"pyobvector", "pyseekdb", "pylibseekdb"}) + + decision = platform_defaults.choose_default_database_provider() + + assert decision.provider == "oceanbase" + assert decision.defaulted is True + assert decision.embedded_seekdb_available is True + + +def test_linux_without_seekdb_defaults_to_sqlite(monkeypatch): + monkeypatch.delenv("DATABASE_PROVIDER", raising=False) + monkeypatch.delenv("OCEANBASE_HOST", raising=False) + monkeypatch.setattr(platform_defaults.sys, "platform", "linux") + _mock_find_spec(monkeypatch, {"pyobvector", "pyseekdb"}) + + assert platform_defaults.default_database_provider() == "sqlite" + + +def test_explicit_database_provider_wins(monkeypatch): + monkeypatch.setenv("DATABASE_PROVIDER", "postgres") + monkeypatch.setenv("OCEANBASE_HOST", "ob.example.com") + monkeypatch.setattr(platform_defaults.sys, "platform", "linux") + _mock_find_spec(monkeypatch, {"pyobvector", "pyseekdb", "pylibseekdb"}) + + decision = platform_defaults.choose_default_database_provider() + + assert decision.provider == "postgres" + assert decision.defaulted is False + + +def test_oceanbase_host_selects_remote_oceanbase(monkeypatch): + monkeypatch.delenv("DATABASE_PROVIDER", raising=False) + monkeypatch.setenv("OCEANBASE_HOST", "ob.example.com") + monkeypatch.setattr(platform_defaults.sys, "platform", "win32") + _mock_find_spec(monkeypatch, set()) + + decision = platform_defaults.choose_default_database_provider() + + assert decision.provider == "oceanbase" + assert decision.defaulted is True + + +def test_sqlite_capabilities_include_full_stack_warning(): + capabilities = platform_defaults.storage_capabilities("sqlite", defaulted=True) + + assert capabilities["full_stack_available"] is False + assert "Graph Store is not available" in capabilities["limitations"] + assert "OceanBase" in capabilities["recommendation"] From 50a9b5ce7c48932603c6967e889433e0cdcb5ba6 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Tue, 9 Jun 2026 02:21:36 +0800 Subject: [PATCH 2/3] fix(ci): stabilize storage probes and extension build --- apps/vscode-extension/package-lock.json | 59 +++++++++++++++++++++ src/powermem/platform_defaults.py | 19 ++++--- tests/unit/test_storage_default_provider.py | 17 ++++++ 3 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 apps/vscode-extension/package-lock.json diff --git a/apps/vscode-extension/package-lock.json b/apps/vscode-extension/package-lock.json new file mode 100644 index 000000000..ba729f4c9 --- /dev/null +++ b/apps/vscode-extension/package-lock.json @@ -0,0 +1,59 @@ +{ + "name": "powermem-vscode", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "powermem-vscode", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^18.x", + "@types/vscode": "^1.104.0", + "typescript": "^5.3.0" + }, + "engines": { + "vscode": "^1.104.0" + } + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/vscode": { + "version": "1.120.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.120.0.tgz", + "integrity": "sha512-feaT4Rst+FkTch5zz/ZbNCxoIvo55YU80Be2kiL7OJcod4+CUYf2lUBPdIJzozNnSEMq1VRTGrWEcCGFB3fBmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/src/powermem/platform_defaults.py b/src/powermem/platform_defaults.py index 718ceef7d..be8160f6b 100644 --- a/src/powermem/platform_defaults.py +++ b/src/powermem/platform_defaults.py @@ -40,6 +40,13 @@ def oceanbase_remote_configured() -> bool: return bool(os.environ.get("OCEANBASE_HOST")) +def _module_available(module_name: str) -> bool: + try: + return importlib.util.find_spec(module_name) is not None + except (ImportError, ModuleNotFoundError, ValueError): + return False + + def embedded_seekdb_available() -> bool: """Return whether embedded SeekDB can be used on this platform. @@ -50,9 +57,9 @@ def embedded_seekdb_available() -> bool: if not sys.platform.startswith("linux"): return False return ( - importlib.util.find_spec("pyobvector") is not None - and importlib.util.find_spec("pyseekdb") is not None - and importlib.util.find_spec("pylibseekdb") is not None + _module_available("pyobvector") + and _module_available("pyseekdb") + and _module_available("pylibseekdb") ) @@ -129,9 +136,9 @@ def sqlite_capability_warning(provider: str, defaulted: bool | None = None) -> s def embedded_seekdb_unavailable_message() -> str: return ( "Embedded SeekDB is not available on this platform or installation. " - f"platform={sys.platform}, pyobvector={importlib.util.find_spec('pyobvector') is not None}, " - f"pyseekdb={importlib.util.find_spec('pyseekdb') is not None}, " - f"pylibseekdb={importlib.util.find_spec('pylibseekdb') is not None}. " + f"platform={sys.platform}, pyobvector={_module_available('pyobvector')}, " + f"pyseekdb={_module_available('pyseekdb')}, " + f"pylibseekdb={_module_available('pylibseekdb')}. " 'Use DATABASE_PROVIDER=sqlite for local basic storage, install "powermem[seekdb]" ' "on Linux for embedded SeekDB, or set OCEANBASE_HOST for a remote OceanBase cluster." ) diff --git a/tests/unit/test_storage_default_provider.py b/tests/unit/test_storage_default_provider.py index 409f18cab..e1afade52 100644 --- a/tests/unit/test_storage_default_provider.py +++ b/tests/unit/test_storage_default_provider.py @@ -45,6 +45,23 @@ def test_linux_without_seekdb_defaults_to_sqlite(monkeypatch): assert platform_defaults.default_database_provider() == "sqlite" +def test_modules_with_missing_spec_are_treated_as_unavailable(monkeypatch): + monkeypatch.delenv("DATABASE_PROVIDER", raising=False) + monkeypatch.delenv("OCEANBASE_HOST", raising=False) + monkeypatch.setattr(platform_defaults.sys, "platform", "linux") + + def bad_find_spec(name): + if name == "pyobvector": + raise ValueError("pyobvector.__spec__ is not set") + return object() + + monkeypatch.setattr(platform_defaults.importlib.util, "find_spec", bad_find_spec) + + assert platform_defaults.embedded_seekdb_available() is False + assert platform_defaults.default_database_provider() == "sqlite" + assert "pyobvector=False" in platform_defaults.embedded_seekdb_unavailable_message() + + def test_explicit_database_provider_wins(monkeypatch): monkeypatch.setenv("DATABASE_PROVIDER", "postgres") monkeypatch.setenv("OCEANBASE_HOST", "ob.example.com") From 2a70a0fb91aa90f50350a4c6d56a4c8901a229a4 Mon Sep 17 00:00:00 2001 From: knqiufan Date: Tue, 23 Jun 2026 09:39:33 +0800 Subject: [PATCH 3/3] fix(storage): align defaults with env-file config --- src/powermem/cli/commands/config.py | 6 +- src/powermem/platform_defaults.py | 76 +++++++++++++++++- src/server/api/v1/system.py | 2 - src/server/main.py | 4 +- src/server/models/response.py | 1 - src/server/utils/service_errors.py | 1 - tests/unit/server/test_system_health.py | 34 ++++++++ tests/unit/test_seekdb_default_storage.py | 11 ++- tests/unit/test_storage_default_provider.py | 89 +++++++++++++++++++++ 9 files changed, 212 insertions(+), 12 deletions(-) create mode 100644 tests/unit/server/test_system_health.py diff --git a/src/powermem/cli/commands/config.py b/src/powermem/cli/commands/config.py index d99919219..37db869b4 100644 --- a/src/powermem/cli/commands/config.py +++ b/src/powermem/cli/commands/config.py @@ -24,6 +24,7 @@ print_info, ) from powermem.platform_defaults import ( + database_provider_explicitly_configured, default_database_provider, embedded_seekdb_available, embedded_seekdb_unavailable_message, @@ -311,7 +312,10 @@ def _validate_loaded_config(config: Dict[str, Any], strict: bool) -> Dict[str, A elif not embedded_seekdb_available(): errors.append(embedded_seekdb_unavailable_message()) elif provider == "sqlite": - warning = sqlite_capability_warning(provider, defaulted=not bool(os.environ.get("DATABASE_PROVIDER"))) + warning = sqlite_capability_warning( + provider, + defaulted=not database_provider_explicitly_configured(), + ) if warning: warnings.append(warning) elif provider in ("postgres", "pgvector"): diff --git a/src/powermem/platform_defaults.py b/src/powermem/platform_defaults.py index be8160f6b..995a909b2 100644 --- a/src/powermem/platform_defaults.py +++ b/src/powermem/platform_defaults.py @@ -6,6 +6,7 @@ import os import sys from dataclasses import dataclass +from pathlib import Path from typing import Any, Dict @@ -32,12 +33,81 @@ class StorageDefaultDecision: embedded_seekdb_available: bool +def _configured_env_files() -> list[str]: + env_files: list[str] = [] + cli_env = os.environ.get("POWERMEM_ENV_FILE") + if cli_env: + env_files.append(os.path.expanduser(os.path.expandvars(cli_env.strip()))) + + try: + from powermem.settings import _DEFAULT_ENV_FILE + except Exception: + _DEFAULT_ENV_FILE = None + + if _DEFAULT_ENV_FILE: + env_files.append(str(_DEFAULT_ENV_FILE)) + + seen = set() + result = [] + for path in env_files: + if not path or path in seen: + continue + seen.add(path) + result.append(path) + return result + + +def _read_env_file_value(path: str, key: str) -> str | None: + env_path = Path(path) + if not env_path.is_file(): + return None + + try: + from dotenv import dotenv_values + + values = dotenv_values(env_path) + for env_key, value in values.items(): + if env_key and env_key.upper() == key.upper() and value: + return value + return None + except Exception: + pass + + try: + lines = env_path.read_text(encoding="utf-8").splitlines() + except OSError: + return None + + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + env_key, value = stripped.split("=", 1) + if env_key.strip().upper() != key.upper(): + continue + value = value.strip().strip("'\"") + return value or None + return None + + +def _settings_value(key: str) -> str | None: + for env_key, value in os.environ.items(): + if env_key.upper() == key.upper() and value: + return value + + for env_file in _configured_env_files(): + value = _read_env_file_value(env_file, key) + if value: + return value + return None + + def database_provider_explicitly_configured() -> bool: - return bool(os.environ.get("DATABASE_PROVIDER")) + return bool(_settings_value("DATABASE_PROVIDER")) def oceanbase_remote_configured() -> bool: - return bool(os.environ.get("OCEANBASE_HOST")) + return bool(_settings_value("OCEANBASE_HOST")) def _module_available(module_name: str) -> bool: @@ -64,7 +134,7 @@ def embedded_seekdb_available() -> bool: def choose_default_database_provider() -> StorageDefaultDecision: - configured_provider = os.environ.get("DATABASE_PROVIDER") + configured_provider = _settings_value("DATABASE_PROVIDER") if configured_provider: return StorageDefaultDecision( provider=configured_provider.lower(), diff --git a/src/server/api/v1/system.py b/src/server/api/v1/system.py index 0919b7856..f2d74a4fd 100644 --- a/src/server/api/v1/system.py +++ b/src/server/api/v1/system.py @@ -36,12 +36,10 @@ async def health_check(request: Request): """Health check endpoint""" ready = bool(getattr(request.app.state, "service_ready", False)) - startup_error = getattr(request.app.state, "service_startup_error", None) health = HealthResponse( status="healthy" if ready else "degraded", memory_service_ready=ready, storage_type=getattr(request.app.state, "storage_type", None), - startup_error=(str(startup_error)[:200] if startup_error else None), ) return APIResponse( diff --git a/src/server/main.py b/src/server/main.py index d266951d0..a434ea93d 100644 --- a/src/server/main.py +++ b/src/server/main.py @@ -21,7 +21,6 @@ from .middleware.auth import verify_api_key import logging -import os from importlib.util import find_spec # Setup logging @@ -67,6 +66,7 @@ async def _service_lifespan(app: FastAPI): app.state.storage_capabilities = None try: from powermem.platform_defaults import ( + database_provider_explicitly_configured, sqlite_capability_warning, storage_capabilities, ) @@ -77,7 +77,7 @@ async def _service_lifespan(app: FastAPI): app.state.agent_service = AgentService() storage_type = getattr(app.state.memory_service.memory, "storage_type", None) app.state.storage_type = storage_type - defaulted = not bool(os.environ.get("DATABASE_PROVIDER")) + defaulted = not database_provider_explicitly_configured() app.state.storage_capabilities = storage_capabilities( storage_type or "", defaulted=defaulted, diff --git a/src/server/models/response.py b/src/server/models/response.py index 5e8385121..ba99a52d6 100644 --- a/src/server/models/response.py +++ b/src/server/models/response.py @@ -156,7 +156,6 @@ class HealthResponse(BaseModel): status: str = Field(..., description="Health status") memory_service_ready: Optional[bool] = Field(None, description="Whether the memory service initialized successfully") storage_type: Optional[str] = Field(None, description="Storage backend type") - startup_error: Optional[str] = Field(None, description="Startup error if service initialization failed") timestamp: datetime = Field(default_factory=get_current_datetime, description="Check timestamp") @field_serializer('timestamp') diff --git a/src/server/utils/service_errors.py b/src/server/utils/service_errors.py index decdf51f9..1ee19d555 100644 --- a/src/server/utils/service_errors.py +++ b/src/server/utils/service_errors.py @@ -16,4 +16,3 @@ def service_unavailable_message(request: Request, service_name: str) -> str: 'with "powermem[seekdb]" or configure remote OceanBase with OCEANBASE_HOST.' ) return message - diff --git a/tests/unit/server/test_system_health.py b/tests/unit/server/test_system_health.py new file mode 100644 index 000000000..59753564e --- /dev/null +++ b/tests/unit/server/test_system_health.py @@ -0,0 +1,34 @@ +import json + +import pytest + + +def test_public_health_does_not_expose_startup_error(): + pytest.importorskip("fastapi", exc_type=ImportError) + + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from server.api.v1.system import router + + app = FastAPI() + app.state.service_ready = False + app.state.storage_type = "sqlite" + app.state.service_startup_error = ( + "failed to connect to postgresql://user:secret@db.internal/powermem " + "from C:/sensitive/path" + ) + app.include_router(router, prefix="/api/v1") + + response = TestClient(app).get("/api/v1/system/health") + + assert response.status_code == 200 + body = response.json() + assert body["data"]["status"] == "degraded" + assert body["data"]["memory_service_ready"] is False + assert "startup_error" not in body["data"] + + response_text = json.dumps(body) + assert "secret" not in response_text + assert "db.internal" not in response_text + assert "sensitive/path" not in response_text diff --git a/tests/unit/test_seekdb_default_storage.py b/tests/unit/test_seekdb_default_storage.py index 9912ae675..86b1df270 100644 --- a/tests/unit/test_seekdb_default_storage.py +++ b/tests/unit/test_seekdb_default_storage.py @@ -17,6 +17,7 @@ def test_memory_config_default_storage_is_oceanbase_when_embedded_seekdb_availab import powermem.platform_defaults as platform_defaults + monkeypatch.setattr(platform_defaults, "_configured_env_files", lambda: []) monkeypatch.setattr(platform_defaults.sys, "platform", "linux") monkeypatch.setattr( platform_defaults.importlib.util, @@ -38,11 +39,17 @@ def test_memory_config_default_storage_is_oceanbase_when_embedded_seekdb_availab def test_database_settings_default_provider_matches_platform_helper(monkeypatch): monkeypatch.delenv("DATABASE_PROVIDER", raising=False) + monkeypatch.delenv("OCEANBASE_HOST", raising=False) + import powermem.platform_defaults as platform_defaults from powermem.config_loader import DatabaseSettings - from powermem.platform_defaults import default_database_provider - assert DatabaseSettings().provider == default_database_provider() + monkeypatch.setattr(platform_defaults, "_configured_env_files", lambda: []) + model_config = dict(DatabaseSettings.model_config) + model_config["env_file"] = None + monkeypatch.setattr(DatabaseSettings, "model_config", model_config) + + assert DatabaseSettings().provider == platform_defaults.default_database_provider() def test_oceanbase_provider_picks_up_remote_host(monkeypatch): diff --git a/tests/unit/test_storage_default_provider.py b/tests/unit/test_storage_default_provider.py index e1afade52..f1da92c16 100644 --- a/tests/unit/test_storage_default_provider.py +++ b/tests/unit/test_storage_default_provider.py @@ -3,6 +3,10 @@ import powermem.platform_defaults as platform_defaults +def _ignore_env_files(monkeypatch): + monkeypatch.setattr(platform_defaults, "_configured_env_files", lambda: []) + + def _mock_find_spec(monkeypatch, available: set[str]): def fake_find_spec(name): return object() if name in available else None @@ -13,6 +17,7 @@ def fake_find_spec(name): def test_windows_zero_config_defaults_to_sqlite(monkeypatch): monkeypatch.delenv("DATABASE_PROVIDER", raising=False) monkeypatch.delenv("OCEANBASE_HOST", raising=False) + _ignore_env_files(monkeypatch) monkeypatch.setattr(platform_defaults.sys, "platform", "win32") _mock_find_spec(monkeypatch, {"pyobvector", "pyseekdb"}) @@ -26,6 +31,7 @@ def test_windows_zero_config_defaults_to_sqlite(monkeypatch): def test_linux_with_seekdb_defaults_to_oceanbase(monkeypatch): monkeypatch.delenv("DATABASE_PROVIDER", raising=False) monkeypatch.delenv("OCEANBASE_HOST", raising=False) + _ignore_env_files(monkeypatch) monkeypatch.setattr(platform_defaults.sys, "platform", "linux") _mock_find_spec(monkeypatch, {"pyobvector", "pyseekdb", "pylibseekdb"}) @@ -39,6 +45,7 @@ def test_linux_with_seekdb_defaults_to_oceanbase(monkeypatch): def test_linux_without_seekdb_defaults_to_sqlite(monkeypatch): monkeypatch.delenv("DATABASE_PROVIDER", raising=False) monkeypatch.delenv("OCEANBASE_HOST", raising=False) + _ignore_env_files(monkeypatch) monkeypatch.setattr(platform_defaults.sys, "platform", "linux") _mock_find_spec(monkeypatch, {"pyobvector", "pyseekdb"}) @@ -48,6 +55,7 @@ def test_linux_without_seekdb_defaults_to_sqlite(monkeypatch): def test_modules_with_missing_spec_are_treated_as_unavailable(monkeypatch): monkeypatch.delenv("DATABASE_PROVIDER", raising=False) monkeypatch.delenv("OCEANBASE_HOST", raising=False) + _ignore_env_files(monkeypatch) monkeypatch.setattr(platform_defaults.sys, "platform", "linux") def bad_find_spec(name): @@ -65,6 +73,7 @@ def bad_find_spec(name): def test_explicit_database_provider_wins(monkeypatch): monkeypatch.setenv("DATABASE_PROVIDER", "postgres") monkeypatch.setenv("OCEANBASE_HOST", "ob.example.com") + _ignore_env_files(monkeypatch) monkeypatch.setattr(platform_defaults.sys, "platform", "linux") _mock_find_spec(monkeypatch, {"pyobvector", "pyseekdb", "pylibseekdb"}) @@ -77,15 +86,95 @@ def test_explicit_database_provider_wins(monkeypatch): def test_oceanbase_host_selects_remote_oceanbase(monkeypatch): monkeypatch.delenv("DATABASE_PROVIDER", raising=False) monkeypatch.setenv("OCEANBASE_HOST", "ob.example.com") + _ignore_env_files(monkeypatch) + monkeypatch.setattr(platform_defaults.sys, "platform", "win32") + _mock_find_spec(monkeypatch, set()) + + decision = platform_defaults.choose_default_database_provider() + + assert decision.provider == "oceanbase" + assert decision.defaulted is True + + +def test_env_file_database_provider_matches_database_settings(monkeypatch, tmp_path): + monkeypatch.delenv("DATABASE_PROVIDER", raising=False) + monkeypatch.delenv("OCEANBASE_HOST", raising=False) + monkeypatch.setattr(platform_defaults.sys, "platform", "win32") + _mock_find_spec(monkeypatch, set()) + + env_file = tmp_path / ".env" + env_file.write_text("DATABASE_PROVIDER=oceanbase\n", encoding="utf-8") + monkeypatch.setattr( + platform_defaults, + "_configured_env_files", + lambda: [str(env_file)], + ) + + from powermem.config_loader import DatabaseSettings + + model_config = dict(DatabaseSettings.model_config) + model_config["env_file"] = str(env_file) + monkeypatch.setattr(DatabaseSettings, "model_config", model_config) + + assert platform_defaults.default_database_provider() == "oceanbase" + assert DatabaseSettings().provider == "oceanbase" + + +def test_environment_database_provider_overrides_env_file(monkeypatch, tmp_path): + monkeypatch.setenv("DATABASE_PROVIDER", "sqlite") + monkeypatch.delenv("OCEANBASE_HOST", raising=False) + + env_file = tmp_path / ".env" + env_file.write_text("DATABASE_PROVIDER=oceanbase\n", encoding="utf-8") + monkeypatch.setattr( + platform_defaults, + "_configured_env_files", + lambda: [str(env_file)], + ) + + decision = platform_defaults.choose_default_database_provider() + + assert decision.provider == "sqlite" + assert decision.defaulted is False + + +def test_env_file_oceanbase_host_selects_remote_oceanbase(monkeypatch, tmp_path): + monkeypatch.delenv("DATABASE_PROVIDER", raising=False) + monkeypatch.delenv("OCEANBASE_HOST", raising=False) monkeypatch.setattr(platform_defaults.sys, "platform", "win32") _mock_find_spec(monkeypatch, set()) + env_file = tmp_path / ".env" + env_file.write_text("OCEANBASE_HOST=ob.example.com\n", encoding="utf-8") + monkeypatch.setattr( + platform_defaults, + "_configured_env_files", + lambda: [str(env_file)], + ) + decision = platform_defaults.choose_default_database_provider() assert decision.provider == "oceanbase" assert decision.defaulted is True +def test_storage_capabilities_treats_env_file_provider_as_explicit(monkeypatch, tmp_path): + monkeypatch.delenv("DATABASE_PROVIDER", raising=False) + monkeypatch.delenv("OCEANBASE_HOST", raising=False) + + env_file = tmp_path / ".env" + env_file.write_text("DATABASE_PROVIDER=sqlite\n", encoding="utf-8") + monkeypatch.setattr( + platform_defaults, + "_configured_env_files", + lambda: [str(env_file)], + ) + + capabilities = platform_defaults.storage_capabilities("sqlite") + + assert capabilities["defaulted"] is False + + def test_sqlite_capabilities_include_full_stack_warning(): capabilities = platform_defaults.storage_capabilities("sqlite", defaulted=True)