Compare commits

...
Author SHA1 Message Date
alvinfeiandClaude Opus 5.5 2ab04291a9 fix(memhub): the embedding endpoint no longer follows the generation base_url
A remote embedding with no `embedding_base_url` resolved to the generation
`base_url` at runtime (`build_sdk_config`, `vector_collection_bases`, the
probe). The collection name hashes that endpoint, so a proxy or region edit
of the generation endpoint moved writes to a new, empty collection, and
providers without embeddings were sent embedding requests.

- `resolve_embedding_endpoint` no longer inherits; creating or updating a
  remote (non-local_onnx) embedding without an endpoint is refused with
  422 `embedding_base_url_required`. The runtime and probe read only
  `embedding_base_url`. The API key stays shared.
- Opening the profile store pins, once and idempotently, every stored
  remote embedding left without an endpoint to the one it resolved to
  (the generation base_url, or the provider default), so the collection
  name is byte-identical. `read_active_profile` applies the same rule for
  `sdk-migrate`, which may run before Core has opened the store.
- Web: settings write the prefilled endpoint instead of relying on the
  fallback; onboarding writes an empty custom field as the endpoint it
  means. CLI headless configs now get the 422 code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 18:11:20 +08:00
alvinfeiandClaude Opus 5.5 80d3b55723 fix(memhub): reads use the active profile's embedding space
The read handle was always built from `builtin_retrieval_snapshot()`, while
writes follow the active profile, so a custom embedding (2560 dims, say)
wrote `memhub_<sha>_2560` and every read opened the empty built-in 512-dim
collection.

`_sdk_client_handle` now returns an `ActiveProfileReadHandle`. It opens a
`retrieval_view` of the active profile: the profile's embedding fields with
the built-in generation placeholder, so a read never needs the generation
model; a remote OpenAI-compatible embedding gets the profile's shared key.
With no active profile it stays on the built-in space.

A profile change builds a new client and retires the old one, closed once no
task still uses it, as `SdkWriteCoordinator` does (#218). A use is the task
that asked for the client, so every caller (library, search, recall and its
raw fallback, graph, profile, raw, home summary, ask, context, multi-user
routes) keeps its shape; each raw-retention pass runs in its own task.

Refs #242

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 17:50:21 +08:00
alvinfeiandClaude Opus 5.5 b5ec0861f0 fix(memhub): migrate without a model profile and check readback against the pre-migration store
#234: with no active model profile the migration aborted in preflight with
`sdk_migration_model_profile_required`. No profile means no generation
model, not no embedding: the vectors are the built-in model's, the space the
Runtime reads with. The runner, and `sdk-migration-status`, now use
`builtin_retrieval_snapshot()` for reattach, the collection bases and the
readback client; `runtime_sdk_client` resolves no secret for a key-less
profile. When legacy memories have vectors only under another embedding
space (a profile since deleted), the preflight stops with
`sdk_migration_vector_profile_missing` before anything changes.

#229/#240: the readback required every confirmed governed chat id to read
back, but a 1.0.4 library can hold confirmed rows whose SDK node was merged
or deleted before the upgrade (658 confirmed, 657 readable, upgrade failed).
The ids in the vector store are now read before reattach; chat is held to
the confirmed ids present there, and the rest is only counted as
`chat_missing_before_migration`. A memory present before the migration but
unreadable after it still fails, and if none of the confirmed chats is in
the store none is excused.

Fixes #234, Fixes #229, Fixes #240

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 17:15:51 +08:00
alvinfeiandClaude Opus 5.5 47e938da1b fix(memhub): drop grant triggers that read a table the cutover dropped
The two `source_grants` triggers of the visibility cache select from
`governed_memory_evidence`, which the SDK cutover drops. SQLite resolves a
trigger body only when it fires, so every grant insert or update on a
migrated home failed with "no such table": revoke, connect and the
multi-user Hermes connect all answered 503.

`install_triggers` now installs a trigger only when every table its body
names exists, and drops an old copy otherwise. Startup on SDK authority
(which does not install the cache) and the cutover cleanup drop the stale
copies an earlier build left behind.

Fixes #233

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 16:20:34 +08:00
35 changed files with 1110 additions and 82 deletions

No files matched your search

+2
View File
@@ -4155,6 +4155,8 @@ const SDK_MIGRATION_FAILURES: Record<string, string> = {
"核对发现新版本还读不到你的全部记忆,没有切换数据源。",
sdk_migration_model_profile_required:
"迁移需要先有可用的模型配置,用来核对新版本能否读回记忆。请先用 memhub models setup 完成配置,再运行 memhub restart 重试。",
sdk_migration_vector_profile_missing:
"记忆的向量来自一个已删除的模型配置,当前配置读不回它们,所以没有迁移。请重新添加并启用那个模型配置,再运行 memhub restart 重试。",
};
const MIGRATION_ISSUES_URL =
+21 -4
View File
@@ -94,7 +94,6 @@ from one_memory.hy_runtime_config import (
from one_memory.hy_sdk_runtime import (
AmbiguousMemoryIdError,
SdkClientHandle,
builtin_retrieval_snapshot,
close_all_shared_runtimes,
hold_stores_offline,
release_stores,
@@ -190,6 +189,7 @@ from one_memory.model_presets import seed_preset_profile
from one_memory.model_probe import ModelProber, ModelProbeResult
from one_memory.model_profiles import (
ActiveModelProfileError,
EmbeddingBaseUrlRequiredError,
ModelCreationOperationConflictError,
ModelCreationOperationLookupError,
ModelCreationOperationReceipt,
@@ -229,6 +229,7 @@ from one_memory.sdk_memory_qa import (
)
from one_memory.sdk_memory_runtime import SdkMemoryRuntime
from one_memory.sdk_migration_log import error_chain_text, error_frames
from one_memory.sdk_read_handle import ActiveProfileReadHandle
from one_memory.sdk_write_processor import SdkWriteCoordinator
from one_memory.secrets import (
KeyringSecretStore,
@@ -922,6 +923,16 @@ def create_app(
)
except sqlite3.Error as error:
app.state.memory_visibility_error = str(error)
else:
# A home an earlier build migrated still carries the grant
# triggers whose body reads a dropped table (#233).
try:
await asyncio.to_thread(
memory_visibility.drop_stale_triggers,
database_path,
)
except sqlite3.Error as error:
app.state.memory_visibility_error = str(error)
model_request_budget = (
ModelRequestBudget(
database_path,
@@ -2863,6 +2874,8 @@ def create_app(
def create_model_profile(request: ModelProfileInput) -> ModelProfile:
try:
return _model_profile_store(app).create(request, _secret_store(app))
except EmbeddingBaseUrlRequiredError:
raise HTTPException(422, "embedding_base_url_required") from None
except ModelCreationOperationConflictError:
raise HTTPException(409, "model_creation_operation_conflict") from None
except ModelCreationOperationLookupError:
@@ -2914,6 +2927,8 @@ def create_app(
raise HTTPException(status_code=404, detail="model_profile_not_found") from None
except ModelProfileConflictError:
raise HTTPException(status_code=409, detail="model_profile_conflict") from None
except EmbeddingBaseUrlRequiredError:
raise HTTPException(status_code=422, detail="embedding_base_url_required") from None
except SecretStoreUnavailableError as error:
raise HTTPException(
status_code=503,
@@ -4870,9 +4885,11 @@ async def _search_sdk_raw(
def _sdk_client_handle(app: FastAPI) -> SdkClientHandle:
handle = cast(SdkClientHandle | None, app.state.sdk_client_handle)
if handle is None:
handle = SdkClientHandle(
profile=builtin_retrieval_snapshot(),
api_key=None,
# Reads open the embedding space writes fill: the active profile's,
# the built-in one only when none is active (#242).
handle = ActiveProfileReadHandle(
profiles=_model_profile_store(app),
secrets=_secret_store(app),
data_dir=cast(Settings, app.state.settings).data_dir,
)
app.state.sdk_client_handle = handle
+3 -2
View File
@@ -584,6 +584,7 @@ def _sdk_migration_status(options: Sequence[str], stream: TextIO) -> int:
}
else:
from one_memory.hy_runtime_config import HY_MEMORY_NAMESPACE
from one_memory.hy_sdk_runtime import builtin_retrieval_snapshot
from one_memory.sdk_data_plane_migration import (
MemoryAuthority,
SdkDataPlaneMigrationStore,
@@ -595,14 +596,14 @@ def _sdk_migration_status(options: Sequence[str], stream: TextIO) -> int:
authority = SdkDataPlaneMigrationStore(database_path).authority()
if authority is MemoryAuthority.SDK:
profile = read_active_profile(database_path)
profile = read_active_profile(database_path) or builtin_retrieval_snapshot()
leftovers = bool(
legacy_tables_present(database_path)
or abandoned_staging_present(
data_dir=settings.data_dir,
namespace=HY_MEMORY_NAMESPACE,
)
or (profile is not None and legacy_vector_collections(settings.data_dir, profile))
or legacy_vector_collections(settings.data_dir, profile)
)
state = "required" if leftovers else "not_required"
else:
@@ -217,6 +217,33 @@ def builtin_retrieval_snapshot() -> ModelProfileSnapshot:
)
def retrieval_view(profile: ModelProfileSnapshot) -> ModelProfileSnapshot:
"""The read side of `profile`: its embedding space, and no generation.
Reads must open the collection writes fill (#242), and the collection is
named by the embedding only. Generation stays the built-in placeholder, so
a read never needs the generation model. A remote embedding still needs
the profile's API key, which `build_sdk_config` uses as the embedding key.
"""
provider = profile.embedding_provider_kind or profile.provider_kind
endpoint: str | None = None
if provider is not ProviderKind.LOCAL_ONNX:
endpoint = profile.embedding_base_url
if not endpoint:
raise HySdkRuntimeError("hy_sdk_model_endpoint_missing")
return builtin_retrieval_snapshot().model_copy(
update={
"profile_id": profile.profile_id,
"revision": profile.revision,
"embedding_provider_kind": provider,
"embedding_model": profile.embedding_model,
"embedding_base_url": endpoint,
"embedding_dimensions": profile.embedding_dimensions,
"has_api_key": profile.has_api_key,
}
)
def sdk_relevance_floor() -> float:
raw = os.environ.get("ONE_MEMORY_HY_RELEVANCE_FLOOR", "").strip()
if not raw:
@@ -249,10 +276,8 @@ def build_sdk_config(
embedding_key = None
dimensions = profile.embedding_dimensions or BUILTIN_EMBEDDING_DIMENSIONS
else:
embedding_endpoint = _openai_endpoint(
embedding_provider,
profile.embedding_base_url or profile.base_url,
)
# Never the generation endpoint: the collection name hashes this one.
embedding_endpoint = _openai_endpoint(embedding_provider, profile.embedding_base_url)
embedding_key = "ollama" if embedding_provider is ProviderKind.OLLAMA else api_key
if embedding_provider is ProviderKind.OPENAI_COMPATIBLE and not embedding_key:
raise HySdkRuntimeError("hy_sdk_embedding_api_key_missing")
@@ -467,7 +492,7 @@ def vector_collection_bases(profile: ModelProfileSnapshot) -> tuple[str, tuple[s
endpoint, dimensions = None, profile.embedding_dimensions or BUILTIN_EMBEDDING_DIMENSIONS
legacy_endpoint = "local-onnx"
else:
endpoint = _openai_endpoint(provider, profile.embedding_base_url or profile.base_url)
endpoint = _openai_endpoint(provider, profile.embedding_base_url)
dimensions = profile.embedding_dimensions
legacy_endpoint = endpoint
legacy_model = profile.embedding_model or "bge-small-zh-v1.5"
@@ -160,7 +160,7 @@ def pending(db: sqlite3.Connection, *, limit: int = 100) -> list[str]:
def _trigger_sql() -> list[tuple[str, str]]:
"""(table it depends on, statement) for each trigger."""
"""(table it is on, statement) for each trigger."""
return [
(
"evidence",
@@ -303,9 +303,61 @@ def install_triggers(db: sqlite3.Connection) -> None:
for table, statement in _trigger_sql():
if table not in present:
continue
if not _body_tables(statement) <= present:
# SQLite resolves a trigger body only when it fires, so a body
# naming a dropped table fails the write it rides on (#233: the
# SDK cutover drops `governed_memory_evidence`, and every grant
# write then failed). Not installed, and an old copy is removed.
cursor.execute(f"DROP TRIGGER IF EXISTS {_trigger_name(statement)}")
continue
cursor.execute(statement)
def _trigger_name(statement: str) -> str:
return statement.split("IF NOT EXISTS", 1)[1].split()[0]
def _body_tables(statement: str) -> set[str]:
"""Tables the trigger body reads or writes besides the one it is on."""
_head, _begin, body = statement.partition("BEGIN")
words = body.replace("(", " ").replace(")", " ").split()
return {
name
for keyword, name in zip(words, words[1:], strict=False)
if keyword.upper() in {"FROM", "JOIN", "INTO"}
}
def drop_stale_triggers(database_path: Path) -> tuple[str, ...]:
"""Drop this module's triggers whose body names a table that is gone.
Startup on SDK authority does not install the cache (it is read only on
legacy authority), but a home migrated by an earlier build still carries
the triggers it installed. One whose body reads a dropped table fails
every write to the table it is on, so it is dropped here. Idempotent.
"""
with main_database(database_path).write() as db, db:
cursor = db.cursor()
cursor.row_factory = sqlite3.Row
present = {
str(row["name"])
for row in cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
}
installed = {
str(row["name"])
for row in cursor.execute("SELECT name FROM sqlite_master WHERE type='trigger'")
}
dropped = tuple(
name
for _table, statement in _trigger_sql()
if (name := _trigger_name(statement)) in installed
and not _body_tables(statement) <= present
)
for name in dropped:
cursor.execute(f"DROP TRIGGER IF EXISTS {name}")
return dropped
def startup(database_path: Path) -> None:
"""Idempotent: create the derived tables and triggers. No version change.
@@ -564,7 +564,7 @@ def _embedding_request(
secret: str | None,
) -> tuple[str, str, dict[str, str], dict[str, object]]:
provider = profile.embedding_provider_kind or profile.provider_kind
base_url = (profile.embedding_base_url or profile.base_url or "").rstrip("/")
base_url = (profile.embedding_base_url or "").rstrip("/")
if provider is ProviderKind.OLLAMA:
return (
"POST",
@@ -97,7 +97,6 @@ class ModelProfileInput(BaseModel):
)
embedding_provider, resolved_embedding_base_url = resolve_embedding_endpoint(
provider_kind=self.provider_kind,
base_url=base_url,
embedding_model=embedding_model,
embedding_provider_kind=embedding_provider_kind,
embedding_base_url=embedding_base_url,
@@ -107,6 +106,12 @@ class ModelProfileInput(BaseModel):
ProviderKind.ANTHROPIC,
}:
raise ValueError("embedding provider must be OpenAI-compatible, Ollama, or local ONNX")
if (
embedding_provider is not ProviderKind.LOCAL_ONNX
and resolved_embedding_base_url is None
):
# `ModelProfileStore.create` refuses it with its own code.
return self
if (
embedding_provider is not ProviderKind.LOCAL_ONNX
and resolved_embedding_base_url is not None
@@ -308,11 +313,12 @@ class ModelProfileStore:
f"""
UPDATE {table}
SET embedding_provider_kind = provider_kind,
embedding_base_url = base_url
embedding_base_url = COALESCE(embedding_base_url, base_url)
WHERE embedding_model IS NOT NULL
AND embedding_provider_kind IS NULL
"""
)
_pin_embedding_endpoints(connection, table)
connection.execute(
"""
INSERT OR IGNORE INTO model_profile_versions (
@@ -355,11 +361,11 @@ class ModelProfileStore:
)
embedding_provider_kind, embedding_base_url = resolve_embedding_endpoint(
provider_kind=value.provider_kind,
base_url=base_url,
embedding_model=defaulted_model,
embedding_provider_kind=defaulted_provider,
embedding_base_url=defaulted_base_url,
)
require_embedding_base_url(embedding_provider_kind, embedding_base_url)
try:
with self._connect(writing=True) as connection:
connection.execute(
@@ -612,7 +618,6 @@ class ModelProfileStore:
)
embedding_provider_kind, embedding_base_url = resolve_embedding_endpoint(
provider_kind=current.provider_kind,
base_url=base_url,
embedding_model=embedding_model,
embedding_provider_kind=(
patch.embedding_provider_kind
@@ -627,6 +632,7 @@ class ModelProfileStore:
)
if embedding_provider_kind in {ProviderKind.HY_DEFAULT, ProviderKind.ANTHROPIC}:
raise ValueError("embedding provider must be OpenAI-compatible, Ollama, or local ONNX")
require_embedding_base_url(embedding_provider_kind, embedding_base_url)
if (
embedding_provider_kind is not ProviderKind.LOCAL_ONNX
and embedding_base_url is not None
@@ -1167,21 +1173,77 @@ def attach_disable_thinking(
def resolve_embedding_endpoint(
*,
provider_kind: ProviderKind,
base_url: str | None,
embedding_model: str | None,
embedding_provider_kind: ProviderKind | None,
embedding_base_url: str | None,
) -> tuple[ProviderKind | None, str | None]:
"""The embedding provider and the endpoint exactly as given.
The endpoint never follows the generation `base_url`: the collection name
hashes it, so a proxy or region edit of the generation endpoint moved
writes to a new, empty collection, and providers without embeddings were
sent embedding requests. `require_embedding_base_url` refuses a remote
embedding without one.
"""
if embedding_model is None:
return None, None
if embedding_provider_kind is ProviderKind.LOCAL_ONNX:
return ProviderKind.LOCAL_ONNX, None
if embedding_provider_kind is None:
return provider_kind, embedding_base_url or base_url
return (
embedding_provider_kind,
embedding_base_url or DEFAULT_BASE_URLS.get(embedding_provider_kind),
)
return embedding_provider_kind or provider_kind, embedding_base_url
class EmbeddingBaseUrlRequiredError(ValueError):
def __init__(self) -> None:
super().__init__("embedding_base_url_required")
def require_embedding_base_url(provider: ProviderKind | None, base_url: str | None) -> None:
"""A remote embedding must name its endpoint; the UI may offer the generation one."""
if provider not in {None, ProviderKind.LOCAL_ONNX} and not base_url:
raise EmbeddingBaseUrlRequiredError()
def pinned_embedding_base_url(
*,
provider_kind: ProviderKind | str,
base_url: str | None,
embedding_model: str | None,
embedding_provider_kind: ProviderKind | str | None,
embedding_base_url: str | None,
) -> str | None:
"""The endpoint a stored profile's embedding resolved to before it was pinned.
The Runtime used `embedding_base_url or base_url`, and the provider default
only when both were empty (no collection could have existed then). Writing
that value keeps the endpoint, and so the collection name, byte-identical.
"""
provider = ProviderKind(embedding_provider_kind or provider_kind)
if embedding_model is None or embedding_base_url or provider is ProviderKind.LOCAL_ONNX:
return embedding_base_url
return base_url or DEFAULT_BASE_URLS.get(provider)
def _pin_embedding_endpoints(connection: sqlite3.Connection, table: str) -> None:
"""Write the resolved endpoint into every remote embedding left without one."""
rows = connection.execute(
f"SELECT rowid, provider_kind, base_url, embedding_model, embedding_provider_kind "
f"FROM {table} WHERE embedding_model IS NOT NULL AND embedding_base_url IS NULL "
"AND COALESCE(embedding_provider_kind, provider_kind) != 'local_onnx'"
).fetchall()
for row in rows:
connection.execute(
f"UPDATE {table} SET embedding_base_url = ? WHERE rowid = ?",
(
pinned_embedding_base_url(
provider_kind=row["provider_kind"],
base_url=row["base_url"],
embedding_model=row["embedding_model"],
embedding_provider_kind=row["embedding_provider_kind"],
embedding_base_url=None,
),
row["rowid"],
),
)
def validate_model_base_url(value: str) -> None:
@@ -122,7 +122,9 @@ class SdkMemoryRuntime:
while True:
await asyncio.sleep(RAW_RETENTION_CHECK_SECONDS)
try:
removed = await self.prune_expired_raw()
# Its own task, so this pass's use of the read client ends
# with it instead of with this loop (see `sdk_read_handle`).
removed = await asyncio.create_task(self.prune_expired_raw())
if removed:
logger.info("sdk_raw_retention_deleted count=%d", removed)
except asyncio.CancelledError:
@@ -23,6 +23,7 @@ from pathlib import Path
from typing import Any
from one_memory.database_access import main_database
from one_memory.hy_sdk_runtime import builtin_retrieval_snapshot
from one_memory.imports.models import EvidenceRecord
from one_memory.imports.storage import EvidenceStore
from one_memory.memory_server.contracts import (
@@ -51,6 +52,7 @@ from one_memory.sdk_migration_bundle import inspect_sdk_migration_bundle
from one_memory.sdk_migration_verify import (
MemoryReadbackFailed,
ReadbackReport,
expected_memory_ids,
legacy_memory_ids_from,
legacy_memory_ids_from_bundle,
read_active_profile,
@@ -61,6 +63,8 @@ from one_memory.sdk_vector_reattach import (
ReattachedCollection,
legacy_vector_collections,
reattach_legacy_vectors,
stored_memory_ids,
vectors_belong_to_another_space,
)
from one_memory.target_schema import drop_legacy_tables, legacy_tables_present
@@ -457,13 +461,17 @@ def resume_legacy_cleanup(*, data_dir: Path, database_path: Path) -> tuple[str,
bundle_id=marker.backup_bundle_id,
)
from one_memory.hy_runtime_config import HY_MEMORY_NAMESPACE
from one_memory.memory_visibility import drop_stale_triggers
recover_abandoned_staging(
data_dir=data_dir,
database_path=database_path,
namespace=HY_MEMORY_NAMESPACE,
)
return drop_legacy_tables(database_path)
dropped = drop_legacy_tables(database_path)
# The visibility cache's grant triggers read a table just dropped (#233).
drop_stale_triggers(database_path)
return dropped
MIGRATION_PREFIX = "sdk-authority-v2"
@@ -700,13 +708,14 @@ def run_startup_migration(
step["authority"] = authority.value
log.migration_id = authority_migration
with log.step("preflight.profile_read") as step:
profile = read_active_profile(database_path)
step["profile_present"] = profile is not None
active = read_active_profile(database_path)
step["profile_present"] = active is not None
# No active profile means no generation model, not no embedding: the
# vectors are the built-in model's, which is also what the Runtime reads.
profile = active if active is not None else builtin_retrieval_snapshot()
if authority is MemoryAuthority.SDK:
with log.step("preflight.leftovers_scan") as step:
vectors_waiting = profile is not None and bool(
legacy_vector_collections(data_dir, profile)
)
vectors_waiting = bool(legacy_vector_collections(data_dir, profile))
leftovers = bool(legacy_tables_present(database_path)) or abandoned_staging_present(
data_dir=data_dir, namespace=namespace
)
@@ -717,7 +726,6 @@ def run_startup_migration(
reattached: tuple[ReattachedCollection, ...] = ()
readback: ReadbackReport | None = None
if vectors_waiting:
assert profile is not None
progress("vectors")
with log.step("vectors.reattach") as step:
reattached = reattach_legacy_vectors(data_dir, profile)
@@ -769,12 +777,12 @@ def run_startup_migration(
step["chat"] = len(legacy.chat)
step["productivity"] = len(legacy.productivity)
step["users"] = len(legacy.users)
if profile is None and not legacy.empty:
# Without the embedding profile the Runtime's collections cannot be
# named, so neither reattached nor read back. Legacy stays intact.
log.check("preflight.model_profile_check", "sdk_migration_model_profile_required")
raise DataPlaneMigrationError("sdk_migration_model_profile_required")
log.check("preflight.model_profile_check", None)
if active is None and not legacy.empty and vectors_belong_to_another_space(data_dir, profile):
# The memories live in the space of a profile that is no longer active,
# so the built-in model cannot read them. Legacy stays intact.
log.check("preflight.vector_space_check", "sdk_migration_vector_profile_missing")
raise DataPlaneMigrationError("sdk_migration_vector_profile_missing")
log.check("preflight.vector_space_check", None)
with capture_fence(data_dir):
# Builds through 19 left a staged SDK copy beside the original store.
# Settling it before the bundle means the bundle holds the original
@@ -830,17 +838,23 @@ def run_startup_migration(
step.update(preparation.as_document())
reattached = ()
readback = None
if profile is not None:
progress("vectors")
with log.step("vectors.reattach") as step:
reattached = reattach_legacy_vectors(data_dir, profile)
step["collections"] = len(reattached)
if not legacy.empty:
assert profile is not None
# Before reattach touches the store, and after the staging
# recovery above has settled which store is the original.
with log.step("verify.stored_ids") as step:
expected, missing = expected_memory_ids(legacy, stored_memory_ids(data_dir))
step["chat_expected"] = len(expected.chat)
step["chat_missing_before_migration"] = missing
progress("vectors")
with log.step("vectors.reattach") as step:
reattached = reattach_legacy_vectors(data_dir, profile)
step["collections"] = len(reattached)
if not expected.empty:
progress("verify")
with log.step("verify.readback") as step:
with client_for(profile) as client:
readback = verify_readback(client, legacy)
readback = verify_readback(
client, expected, chat_missing_before_migration=missing
)
step.update(readback.as_document())
if not readback.passed:
store.set_phase(migration_id, phase="readback_failed", state="blocked")
@@ -14,12 +14,12 @@ import json
import sqlite3
from collections.abc import Iterator
from contextlib import closing, contextmanager
from dataclasses import dataclass
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any
from one_memory.database_access import main_database
from one_memory.model_profiles import ModelProfileSnapshot
from one_memory.model_profiles import ModelProfileSnapshot, pinned_embedding_base_url
from one_memory.sdk_data_plane_migration import DataPlaneMigrationError
_PRODUCTIVITY_KINDS = frozenset({"coding", "working"})
@@ -47,6 +47,8 @@ class ReadbackReport:
chat_readable: int
productivity_expected: int
productivity_readable: int
#: Confirmed in the mirror, gone from the SDK before this migration began.
chat_missing_before_migration: int = 0
@property
def passed(self) -> bool:
@@ -63,6 +65,7 @@ class ReadbackReport:
"chat_readable": self.chat_readable,
"productivity_expected": self.productivity_expected,
"productivity_readable": self.productivity_readable,
"chat_missing_before_migration": self.chat_missing_before_migration,
"passed": self.passed,
}
@@ -115,6 +118,27 @@ def read_legacy_memory_ids(connection: sqlite3.Connection) -> LegacyMemoryIds:
)
def expected_memory_ids(
legacy: LegacyMemoryIds, stored: frozenset[str]
) -> tuple[LegacyMemoryIds, int]:
"""What the readback must find, and how many confirmed chats it need not.
A 1.0.4 library can hold confirmed rows whose SDK node the SDK had already
merged or deleted: no migration could read those back (658 confirmed, 657
in the store, and the whole upgrade failed). So chat is held to what the
store held before this run, and anything in there must still read back.
If none of the confirmed chats is in the store, that is not history: the
store is empty or is the wrong one. Nothing is excused then, so the
readback still fails on every id instead of checking nothing just before
the mirror is dropped.
"""
chat = tuple(memory_id for memory_id in legacy.chat if memory_id in stored)
if not chat:
return legacy, 0
return replace(legacy, chat=chat), len(legacy.chat) - len(chat)
def legacy_memory_ids_from(database_path: Path) -> LegacyMemoryIds:
with main_database(database_path).read() as connection:
return read_legacy_memory_ids(connection)
@@ -160,7 +184,14 @@ def read_active_profile(database_path: Path) -> ModelProfileSnapshot | None:
model=row["model"],
embedding_model=row["embedding_model"],
embedding_provider_kind=row["embedding_provider_kind"],
embedding_base_url=row["embedding_base_url"],
# Read-only, beside a Core that may not have pinned it yet.
embedding_base_url=pinned_embedding_base_url(
provider_kind=row["provider_kind"],
base_url=row["base_url"],
embedding_model=row["embedding_model"],
embedding_provider_kind=row["embedding_provider_kind"],
embedding_base_url=row["embedding_base_url"],
),
embedding_dimensions=row["embedding_dimensions"],
timeout_seconds=row["timeout_seconds"],
max_concurrency=row["max_concurrency"],
@@ -179,14 +210,18 @@ def runtime_sdk_client(
from one_memory.model_profiles import ModelProfileStore
from one_memory.secrets import KeyringSecretStore, SecretStoreUnavailableError
try:
secret = ModelProfileStore(database_path).resolve_snapshot_secret(
profile.profile_id, profile.revision, KeyringSecretStore()
)
except SecretStoreUnavailableError:
raise DataPlaneMigrationError("sdk_migration_secure_storage_unavailable") from None
if profile.has_api_key and secret is None:
raise DataPlaneMigrationError("sdk_migration_model_secret_unavailable")
secret: str | None = None
# Without a key there is nothing to resolve, and the built-in retrieval
# snapshot has no stored version to look one up by.
if profile.has_api_key:
try:
secret = ModelProfileStore(database_path).resolve_snapshot_secret(
profile.profile_id, profile.revision, KeyringSecretStore()
)
except SecretStoreUnavailableError:
raise DataPlaneMigrationError("sdk_migration_secure_storage_unavailable") from None
if secret is None:
raise DataPlaneMigrationError("sdk_migration_model_secret_unavailable")
lease = acquire_client(profile, api_key=secret, data_dir=data_dir)
try:
yield lease.client
@@ -194,7 +229,9 @@ def runtime_sdk_client(
lease.close()
def verify_readback(client: Any, legacy: LegacyMemoryIds) -> ReadbackReport:
def verify_readback(
client: Any, legacy: LegacyMemoryIds, *, chat_missing_before_migration: int = 0
) -> ReadbackReport:
listed: set[str] = set()
for user_id in legacy.users or ("local",):
offset = 0
@@ -228,5 +265,6 @@ def verify_readback(client: Any, legacy: LegacyMemoryIds) -> ReadbackReport:
chat_readable=sum(1 for memory_id in legacy.chat if readable(memory_id)),
productivity_expected=len(legacy.productivity),
productivity_readable=sum(1 for memory_id in legacy.productivity if readable(memory_id)),
chat_missing_before_migration=chat_missing_before_migration,
)
@@ -0,0 +1,114 @@
"""The read client, kept in the embedding space the active profile writes to.
Every read used to open the built-in 512-dim collection while writes followed
the active profile, so a custom embedding read an empty space (#242). This
handle opens the active profile's `retrieval_view` instead, and the built-in
space only when no profile is active.
A profile change builds a new client and retires the old one; a retired client
is closed once no task still uses it, as `SdkWriteCoordinator` does for writes
(#218). A use is the task that asked for the client: callers keep their
`client = await handle.client()` shape, and the use ends with that task (one
HTTP request, one retention pass).
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
from one_memory.hy_sdk_runtime import (
BUILTIN_RETRIEVAL_PROFILE_ID,
SdkClientHandle,
builtin_retrieval_snapshot,
retrieval_view,
)
from one_memory.hy_sdk_types import HySdkRuntimeError
from one_memory.model_profiles import ModelProfileStore, ProviderKind
from one_memory.secrets import SecretStore, SecretStoreUnavailableError
_BUILTIN = (BUILTIN_RETRIEVAL_PROFILE_ID, 0)
class ActiveProfileReadHandle(SdkClientHandle):
"""An `SdkClientHandle` whose profile follows the active one."""
def __init__(
self,
*,
profiles: ModelProfileStore,
secrets: SecretStore,
data_dir: Path,
) -> None:
super().__init__(builtin_retrieval_snapshot(), api_key=None, data_dir=data_dir)
self.profiles = profiles
self.secrets = secrets
self.data_dir = data_dir
self._handles: dict[tuple[str, int], SdkClientHandle] = {}
#: Tasks in flight per handle; a retired handle closes at zero.
self._uses: dict[SdkClientHandle, int] = {}
self._retired: set[SdkClientHandle] = set()
self._closing: set[asyncio.Task[None]] = set()
self._handle_lock = asyncio.Lock()
async def client(self) -> Any:
identity = await asyncio.to_thread(self.profiles.active_identity) or _BUILTIN
async with self._handle_lock:
handle = self._handles.get(identity)
if handle is None:
# Only on a change of profile: resolving a key can reach the
# system keychain, so it is not done on every read.
handle = await asyncio.to_thread(self._build, identity)
self._handles[identity] = handle
self._retired.update(value for key, value in self._handles.items() if key != identity)
self._handles = {identity: handle}
unused = {value for value in self._retired if not self._uses.get(value)}
self._retired -= unused
self._hold(handle)
await asyncio.gather(*(value.close() for value in unused))
return await handle.client()
async def close(self) -> None:
async with self._handle_lock:
handles = (*self._handles.values(), *self._retired)
self._handles, self._retired, self._uses = {}, set(), {}
await asyncio.gather(*(handle.close() for handle in handles), *self._closing)
def _hold(self, handle: SdkClientHandle) -> None:
"""Count a use of `handle` until the asking task finishes."""
task = asyncio.current_task()
if task is None: # pragma: no cover - always called from a coroutine
return
self._uses[handle] = self._uses.get(handle, 0) + 1
task.add_done_callback(lambda _task: self._release(handle))
def _release(self, handle: SdkClientHandle) -> None:
remaining = self._uses.get(handle, 0) - 1
if remaining > 0:
self._uses[handle] = remaining
return
self._uses.pop(handle, None)
if handle in self._retired:
self._retired.discard(handle)
closing = asyncio.get_running_loop().create_task(handle.close())
self._closing.add(closing)
closing.add_done_callback(self._closing.discard)
def _build(self, identity: tuple[str, int]) -> SdkClientHandle:
if identity == _BUILTIN:
return SdkClientHandle(
builtin_retrieval_snapshot(), api_key=None, data_dir=self.data_dir
)
profile = self.profiles.snapshot(*identity)
view = retrieval_view(profile)
secret: str | None = None
# Only a remote OpenAI-compatible embedding sends the shared key.
if view.embedding_provider_kind is ProviderKind.OPENAI_COMPATIBLE:
try:
secret = self.profiles.resolve_snapshot_secret(*identity, self.secrets)
except SecretStoreUnavailableError:
raise HySdkRuntimeError("secure_storage_unavailable") from None
if secret is None:
raise HySdkRuntimeError("model_secret_unavailable")
return SdkClientHandle(view, api_key=secret, data_dir=self.data_dir)
@@ -9,6 +9,7 @@ one. Runtime code only reaches vectors through the SDK. This module runs from
from __future__ import annotations
import re
import sqlite3
from collections.abc import Iterator
from contextlib import closing
@@ -21,6 +22,8 @@ from one_memory.model_profiles import ModelProfileSnapshot
from one_memory.sdk_data_plane_migration import DataPlaneMigrationError
_BATCH = 500
#: Every name a MemHub build gave an embedding space's base collection.
_SPACE_NAME = re.compile(r"^(?:memhub_[0-9a-f]{16}|one_memory_[0-9a-f]{12})(?:_|$)")
def open_offline_chroma(directory: Path) -> Any:
@@ -51,13 +54,20 @@ def _vector_directory(data_dir: Path) -> Path:
return data_dir / "hy-memory" / "governed-v2" / "vector"
def _stored_names(data_dir: Path) -> tuple[str, ...]:
def _stored_names(data_dir: Path, *, filled: bool = False) -> tuple[str, ...]:
"""Collection names; with `filled`, only those holding at least one row."""
database = _vector_directory(data_dir) / "chroma.sqlite3"
if not database.is_file():
return ()
with closing(sqlite3.connect(f"file:{database}?mode=ro", uri=True)) as connection:
try:
rows = connection.execute("SELECT name FROM collections").fetchall()
rows = connection.execute(
"SELECT DISTINCT collections.name FROM collections "
"JOIN segments ON segments.collection = collections.id "
"JOIN embeddings ON embeddings.segment_id = segments.id"
if filled
else "SELECT name FROM collections"
).fetchall()
except sqlite3.Error:
return ()
return tuple(sorted(str(row[0]) for row in rows))
@@ -80,6 +90,59 @@ def legacy_vector_collections(
return tuple(pairs)
def vectors_belong_to_another_space(data_dir: Path, profile: ModelProfileSnapshot) -> bool:
"""Vectors exist, but only in embedding spaces `profile` does not name.
Counted rows, not names: every Runtime opens its read collection with
`get_or_create`, so an empty collection under the expected base proves
nothing. Read-only, so a preflight can ask before anything changes.
"""
current, legacy_bases = vector_collection_bases(profile)
known = (current, *legacy_bases)
filled = _stored_names(data_dir, filled=True)
return not any(
name == base or name.startswith(f"{base}_") for name in filled for base in known
) and any(_SPACE_NAME.match(name) for name in filled)
def stored_memory_ids(data_dir: Path) -> frozenset[str]:
"""Every id in the vector store as found, across all embedding spaces.
All of them on purpose. A memory stranded under a space the Runtime will
not read must count as present, so the readback then fails on it instead
of excusing it. Both the point id and the SDK's `node_id` are kept: the
SDK derives a point id from a node id that is not a UUID.
Callers must have stopped every SDK writer. A store that cannot be read
raises: "unknown" must never be taken for "absent".
"""
if not (_vector_directory(data_dir) / "chroma.sqlite3").is_file():
return frozenset()
found: set[str] = set()
try:
client = open_offline_chroma(_vector_directory(data_dir))
try:
for collection in client.list_collections():
offset = 0
while True:
page = collection.get(include=["metadatas"], limit=_BATCH, offset=offset)
ids = list(page["ids"])
if not ids:
break
found.update(ids)
found.update(
str(metadata["node_id"])
for metadata in page["metadatas"] or ()
if metadata and metadata.get("node_id")
)
offset += len(ids)
finally:
client.close()
except Exception as error:
raise DataPlaneMigrationError("sdk_migration_vector_store_unreadable") from error
return frozenset(found)
def _records(collection: Any) -> Iterator[dict[str, Any]]:
offset = 0
while True:
+2 -1
View File
@@ -24,7 +24,8 @@ def model_snapshot(
model="memory-model",
embedding_model="embedding-model",
embedding_provider_kind=embedding_provider,
embedding_base_url=embedding_base_url,
# Stored profiles always name it; see `pinned_embedding_base_url`.
embedding_base_url=embedding_base_url or base_url,
embedding_dimensions=768,
timeout_seconds=30,
max_concurrency=1,
+7 -1
View File
@@ -175,7 +175,13 @@ def test_sdk_migrate_reports_each_step_then_the_result(tmp_path, monkeypatch) ->
assert code == 0, output.getvalue()
lines = [json.loads(line) for line in output.getvalue().splitlines()]
assert [line["step"] for line in lines[:-1]] == ["bundle", "prepare", "cutover", "cleanup"]
assert [line["step"] for line in lines[:-1]] == [
"bundle",
"prepare",
"vectors",
"cutover",
"cleanup",
]
assert lines[-1]["state"] == "cutover"
assert lines[-1]["bundle_id"] == f"{lines[-1]['migration_id']}-pre-cutover"
assert not (tmp_path / ".sdk-migration-capture-fence.json").exists()
@@ -26,6 +26,7 @@ def snapshot() -> ModelProfileSnapshot:
base_url="http://127.0.0.1:11434",
model="memory-model",
embedding_model="embedding-model",
embedding_base_url="http://127.0.0.1:11434",
embedding_dimensions=768,
timeout_seconds=30,
max_concurrency=1,
+4
View File
@@ -66,6 +66,7 @@ def _grounded_setup(tmp_path, *, memory_id: str = "memory-real-data"):
base_url="http://127.0.0.1:11434",
model="test-model",
embedding_model="test-embedding",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
)
@@ -263,6 +264,7 @@ def test_answer_uses_only_grounded_confirmed_memory_citations(tmp_path) -> None:
base_url="http://127.0.0.1:11434",
model="test-model",
embedding_model="test-embedding",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
)
@@ -468,6 +470,7 @@ def test_answer_rejects_model_citation_outside_retrieved_scope(tmp_path) -> None
base_url="http://127.0.0.1:11434",
model="test-model",
embedding_model="test-embedding",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
)
@@ -530,6 +533,7 @@ def _compatible_gateway_setup(tmp_path):
base_url="https://api.example.invalid/standard/v1",
model="deepseek-v4-pro",
embedding_model="text-embedding",
embedding_base_url="https://api.example.invalid/standard/v1",
api_key="probe-key",
),
secrets,
@@ -186,6 +186,7 @@ def _setup(tmp_path: Path, *, model: str = "available") -> _Setup:
base_url="https://memory-qa.example.invalid/v1",
model="synthetic-generation",
embedding_model="synthetic-embedding",
embedding_base_url="https://memory-qa.example.invalid/v1",
api_key=SecretStr(_SECRET),
),
secrets,
@@ -25,6 +25,7 @@ def _stores(tmp_path):
provider_kind=ProviderKind.OLLAMA,
model="hy-bot-phi4",
embedding_model="nomic-embed-text",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
)
@@ -83,6 +83,7 @@ def test_task_freezes_active_profile_revision_and_is_idempotent(tmp_path) -> Non
provider_kind=ProviderKind.OLLAMA,
model="qwen",
embedding_model="nomic-embed-text",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
)
@@ -240,6 +241,7 @@ def test_task_extraction_checkpoint_is_idempotent_and_recoverable(tmp_path) -> N
provider_kind=ProviderKind.OLLAMA,
model="qwen",
embedding_model="nomic-embed-text",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
),
@@ -293,6 +295,7 @@ def test_task_waits_for_model_and_retries_with_same_snapshot(tmp_path) -> None:
provider_kind=ProviderKind.OLLAMA,
model="qwen",
embedding_model="nomic-embed-text",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
)
@@ -337,6 +340,7 @@ def test_blocked_stages_name_the_stuck_stage_and_reason(tmp_path) -> None:
provider_kind=ProviderKind.OLLAMA,
model="qwen",
embedding_model="nomic-embed-text",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
),
@@ -433,6 +437,7 @@ def test_task_duration_estimate_ignores_time_the_app_was_closed(tmp_path) -> Non
provider_kind=ProviderKind.OLLAMA,
model="qwen",
embedding_model="nomic-embed-text",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
),
@@ -537,6 +542,7 @@ def test_running_task_is_recovered_after_worker_restart(tmp_path) -> None:
provider_kind=ProviderKind.OLLAMA,
model="qwen",
embedding_model="nomic-embed-text",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
),
@@ -572,6 +578,7 @@ def test_superseded_profile_cancels_only_claimable_tasks(tmp_path) -> None:
provider_kind=ProviderKind.OLLAMA,
model="phi",
embedding_model="nomic-embed-text",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
),
@@ -601,6 +608,7 @@ def test_superseded_profile_cancels_only_claimable_tasks(tmp_path) -> None:
provider_kind=ProviderKind.OLLAMA,
model="qwen",
embedding_model="nomic-embed-text",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
),
@@ -630,6 +638,7 @@ def test_superseded_ingest_policy_cancels_only_old_conversation_tasks(tmp_path)
provider_kind=ProviderKind.OLLAMA,
model="qwen",
embedding_model="nomic-embed-text",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
),
@@ -676,6 +685,7 @@ def test_task_rejects_inactive_profile_and_request_id_reuse(tmp_path) -> None:
provider_kind=ProviderKind.OLLAMA,
model="qwen",
embedding_model="nomic-embed-text",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
)
@@ -1700,6 +1710,7 @@ def _profile_and_tasks(tmp_path) -> tuple[MemoryTaskStore, ModelProfile]:
provider_kind=ProviderKind.OLLAMA,
model="qwen",
embedding_model="nomic-embed-text",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
),
@@ -2058,6 +2069,7 @@ def test_a_failed_task_below_the_ceiling_is_still_claimable(tmp_path) -> None:
provider_kind=ProviderKind.OLLAMA,
model="qwen",
embedding_model="nomic-embed-text",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
),
@@ -55,6 +55,7 @@ def library(tmp_path):
provider_kind=ProviderKind.OLLAMA,
model="hy-bot-phi4",
embedding_model="nomic-embed-text",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
)
@@ -611,3 +612,58 @@ def test_granting_a_source_repeatedly_does_not_raise(library):
with main_database_read(database) as db:
assert memory.memory_id in pending(db)
def test_grant_writes_still_work_after_the_cutover_drops_the_link_table(library):
"""#233: the grant triggers read `governed_memory_evidence`.
The SDK cutover drops it, and SQLite resolves a trigger body only when it
fires, so every grant, revoke and connect then failed with "no such
table". A trigger whose body names a missing table is not installed, and
the copy an earlier startup installed is dropped.
"""
database, *_ = library
from one_memory.imports.candidates import RootCandidateKind
from one_memory.imports.grants import SourceGrantStore
from one_memory.memory_visibility import drop_stale_triggers
startup(database)
with sqlite3.connect(database) as db:
db.execute("DROP TABLE governed_memory_evidence")
def grant_triggers() -> set[str]:
with sqlite3.connect(database) as db:
return {
str(row[0])
for row in db.execute(
"SELECT name FROM sqlite_master "
"WHERE type='trigger' AND tbl_name='source_grants'"
)
}
assert grant_triggers() == {
"memory_visibility_dirty_on_grants",
"memory_visibility_dirty_on_grants_update",
}
# The `evidence` triggers read the dropped table too; the cutover drops
# `evidence` as well, which takes them with it.
assert {
"memory_visibility_dirty_on_grants",
"memory_visibility_dirty_on_grants_update",
} <= set(drop_stale_triggers(database))
assert grant_triggers() == set()
assert drop_stale_triggers(database) == ()
# Installing again on this shape skips them instead of bringing them back.
startup(database)
assert grant_triggers() == set()
folder = database.parent / "cursor"
folder.mkdir(exist_ok=True)
store = SourceGrantStore(database, home=database.parent)
store.grant(
user_id="local",
source_id="cursor",
root=folder,
candidate_kind=RootCandidateKind.DEFAULT,
)
assert store.revoke(user_id="local", source_id="cursor").revoked_at is not None
@@ -51,6 +51,7 @@ def library(tmp_path):
provider_kind=ProviderKind.OLLAMA,
model="hy-bot-phi4",
embedding_model="nomic-embed-text",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
)
@@ -28,6 +28,7 @@ def test_probe_still_measures_embedding_when_structured_output_fails(tmp_path) -
base_url="http://127.0.0.1:11434",
model="plain-model",
embedding_model="nomic-embed-text",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
)
@@ -63,6 +64,7 @@ def test_probe_records_the_real_embedding_width_on_the_profile(tmp_path) -> None
base_url="http://127.0.0.1:11434",
model="qwen3:8b",
embedding_model="nomic-embed-text",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
)
@@ -119,6 +121,7 @@ def test_openai_compatible_probe_checks_model_and_structured_output(tmp_path) ->
base_url="https://models.example.com/v1",
model="memory-model",
embedding_model="embedding-model",
embedding_base_url="https://models.example.com/v1",
api_key=SecretStr(API_KEY),
),
secrets,
@@ -260,6 +263,7 @@ def test_probe_reports_missing_model_without_sending_generation(tmp_path) -> Non
provider_kind=ProviderKind.OLLAMA,
model="memory-model",
embedding_model="embedding-model",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
)
@@ -50,6 +50,7 @@ def test_model_profile_api_never_returns_key_and_enforces_revision(tmp_path) ->
"base_url": "https://models.example.com/v1",
"model": "memory-model",
"embedding_model": "embedding-model",
"embedding_base_url": "https://models.example.com/v1",
"api_key": API_KEY,
},
)
@@ -134,6 +135,7 @@ def test_model_profile_probe_route_returns_only_machine_results(tmp_path) -> Non
"base_url": "https://models.example.com/v1",
"model": "memory-model",
"embedding_model": "embedding-model",
"embedding_base_url": "https://models.example.com/v1",
"api_key": API_KEY,
},
)
@@ -244,6 +246,7 @@ def test_activation_survives_a_corpus_already_imported(tmp_path) -> None:
"base_url": "https://models.example.com/v1",
"model": "memory-model",
"embedding_model": "embedding-model",
"embedding_base_url": "https://models.example.com/v1",
"api_key": API_KEY,
},
)
@@ -198,6 +198,7 @@ def test_active_profile_can_be_deleted_without_changing_other_profiles(tmp_path)
provider_kind=ProviderKind.OLLAMA,
model="other",
embedding_model="nomic-embed-text",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
)
@@ -208,6 +209,7 @@ def test_active_profile_can_be_deleted_without_changing_other_profiles(tmp_path)
base_url="https://models.example.com/v1",
model="qwen",
embedding_model="nomic-embed-text",
embedding_base_url="https://models.example.com/v1",
api_key=SecretStr(API_KEY),
),
secrets,
@@ -349,6 +351,7 @@ def _probed(store, secrets, *, structured: bool, generates: bool = True):
provider_kind=ProviderKind.OLLAMA,
model="qwen",
embedding_model="nomic-embed-text",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
)
@@ -394,3 +397,102 @@ def test_a_model_that_cannot_generate_still_cannot_be_turned_on(tmp_path) -> Non
with pytest.raises(ModelProfileUnavailableError):
store.activate(profile.profile_id, secrets)
def _space(store: ModelProfileStore, profile_id: str) -> str:
"""The embedding space's collection base, as the Runtime names it."""
from one_memory.hy_sdk_runtime import vector_collection_bases
identity = (profile_id, store.require(profile_id, InMemorySecretStore()).revision)
return vector_collection_bases(store.snapshot(*identity))[0]
def test_a_remote_embedding_must_name_its_endpoint(tmp_path) -> None:
"""The endpoint no longer follows the generation `base_url`."""
from one_memory.model_profiles import EmbeddingBaseUrlRequiredError
store = ModelProfileStore(tmp_path / "one-memory.sqlite3")
secrets = InMemorySecretStore()
unnamed = ModelProfileInput(
name="Remote",
provider_kind=ProviderKind.OPENAI_COMPATIBLE,
base_url="https://models.example.com/v1",
model="qwen",
embedding_model="text-embedding-3-small",
api_key=SecretStr(API_KEY),
)
with pytest.raises(EmbeddingBaseUrlRequiredError):
store.create(unnamed, secrets)
assert store.list(secrets) == []
named = store.create(
unnamed.model_copy(update={"embedding_base_url": "https://models.example.com/v1"}),
secrets,
)
with pytest.raises(EmbeddingBaseUrlRequiredError):
store.update(
named.profile_id,
ModelProfilePatch(
expected_revision=named.revision,
embedding_provider_kind=ProviderKind.OLLAMA,
embedding_base_url="",
),
secrets,
)
def test_stored_profiles_get_the_endpoint_they_resolved_to_and_keep_their_space(
tmp_path,
) -> None:
"""An older build stored remote embeddings with a NULL endpoint and resolved
it to the generation `base_url` at runtime. Opening the store writes that
endpoint in once, so the collection, and the memories in it, stay put; a
later edit of the generation endpoint no longer moves them.
"""
from one_memory.hy_sdk_runtime import vector_collection_bases
database = tmp_path / "one-memory.sqlite3"
store = ModelProfileStore(database)
secrets = InMemorySecretStore()
profile = store.create(
ModelProfileInput(
name="Remote",
provider_kind=ProviderKind.OPENAI_COMPATIBLE,
base_url="https://gateway.example.com/v1",
model="qwen",
embedding_model="text-embedding-3-small",
embedding_base_url="https://gateway.example.com/v1",
embedding_dimensions=1536,
api_key=SecretStr(API_KEY),
),
secrets,
)
identity = (profile.profile_id, profile.revision)
# The space the older build's Runtime resolved: `embedding_base_url or base_url`.
before = vector_collection_bases(store.snapshot(*identity))[0]
with sqlite3.connect(database) as db:
# The shape an older build stored.
db.execute("UPDATE model_profiles SET embedding_base_url=NULL")
db.execute("UPDATE model_profile_versions SET embedding_base_url=NULL")
migrated = ModelProfileStore(database)
assert migrated.snapshot(*identity).embedding_base_url == "https://gateway.example.com/v1"
assert _space(migrated, profile.profile_id) == before
# Idempotent: opening again changes nothing.
assert _space(ModelProfileStore(database), profile.profile_id) == before
with sqlite3.connect(database) as db:
assert db.execute(
"SELECT COUNT(*) FROM model_profile_versions WHERE embedding_base_url IS NULL"
).fetchone() == (0,)
edited = migrated.update(
profile.profile_id,
ModelProfilePatch(
expected_revision=profile.revision,
base_url="https://gateway.example.com/eu/v1",
),
secrets,
)
assert edited.base_url == "https://gateway.example.com/eu/v1"
assert edited.embedding_base_url == "https://gateway.example.com/v1"
assert _space(migrated, profile.profile_id) == before
@@ -650,6 +650,75 @@ def test_two_hermes_grants_each_read_their_own_root_into_separate_batches(tmp_pa
asyncio.run(scenario())
@pytest.mark.fresh_sdk
def test_a_migrated_home_with_the_old_grant_triggers_can_connect_and_revoke(tmp_path) -> None:
"""#233: a home an earlier build migrated kept two `source_grants` triggers
whose body reads the dropped `governed_memory_evidence`. Every grant write
then failed, so connecting and revoking answered 503. Startup drops them.
"""
stale = """
CREATE TRIGGER memory_visibility_dirty_on_grants{suffix}
AFTER {event} ON source_grants
BEGIN
INSERT INTO memory_visibility_dirty (memory_id)
SELECT DISTINCT link.memory_id
FROM governed_memory_evidence link
JOIN evidence e ON e.evidence_id = link.evidence_id
WHERE e.source_id = NEW.source_id
ON CONFLICT(memory_id) DO NOTHING;
END;"""
async def scenario():
home = tmp_path / "home"
_hermes, wife = _hermes_homes(home)
app = application(tmp_path, home=home)
async with app.router.lifespan_context(app):
database = app.state.settings.data_dir / "one-memory.sqlite3"
with sqlite3.connect(database) as db:
db.execute("CREATE TABLE IF NOT EXISTS memory_visibility_dirty (memory_id TEXT)")
db.execute(stale.format(suffix="", event="INSERT"))
db.execute(stale.format(suffix="_update", event="UPDATE"))
app = application(tmp_path, home=home)
async with app.router.lifespan_context(app), client_for(app) as client:
location = (await client.post("/api/v1/sources/hermes/location")).json()
local = await client.post(
"/api/v1/sources/connect",
json={
"source_ids": ["hermes"],
"request_id": str(uuid4()),
"source_roots": {"hermes": location["root_id"]},
},
)
assert local.status_code == 200, local.text
profile = await client.post(
"/api/v1/sources/connect",
json={
"source_ids": ["hermes"],
"request_id": str(uuid4()),
"user_id": "wife",
"root": str(wife),
},
)
assert profile.status_code == 200, profile.text
for user_id in ("wife", "local"):
revoked = await client.post(
"/api/v1/sources/hermes/revoke", params={"user_id": user_id}
)
assert revoked.status_code == 200, revoked.text
assert revoked.json()["revoked_at"] is not None
with sqlite3.connect(database) as db:
assert (
db.execute(
"SELECT COUNT(*) FROM sqlite_master "
"WHERE type='trigger' AND tbl_name='source_grants'"
).fetchone()[0]
== 0
)
asyncio.run(scenario())
@pytest.mark.fresh_sdk
def test_only_hermes_takes_another_user_or_a_custom_root(tmp_path) -> None:
async def scenario():
@@ -40,6 +40,7 @@ def library(tmp_path):
provider_kind=ProviderKind.OLLAMA,
model="hy-bot-phi4",
embedding_model="nomic-embed-text",
embedding_base_url="http://127.0.0.1:11434",
),
secrets,
)
@@ -708,6 +708,7 @@ def test_cutover_waits_until_the_new_runtime_reads_every_chat_memory(store, acti
"chat_readable": 1,
"productivity_expected": 0,
"productivity_readable": 0,
"chat_missing_before_migration": 0,
"passed": False,
}
assert store.authority() is MemoryAuthority.LEGACY
@@ -774,18 +775,186 @@ def test_retired_coding_experiences_do_not_block_but_an_empty_store_does(store,
assert (result.readback.productivity_expected, result.readback.productivity_readable) == (2, 1)
def test_memories_without_a_model_profile_keep_legacy_authority(store, monkeypatch):
def test_a_home_without_a_model_profile_migrates_on_the_builtin_embedding(store, monkeypatch):
"""#234: no generation model was ever configured, and the upgrade stopped.
No profile means no LLM, not no embedding: the vectors are the built-in
model's, the same space the Runtime reads with. They are reattached and
read back through it, and the client is opened without resolving a key.
"""
from one_memory.hy_sdk_runtime import builtin_retrieval_snapshot, vector_collection_bases
monkeypatch.setattr("one_memory.sdk_migration_runner.read_active_profile", lambda _db: None)
home = store.database_path.parent
builtin = builtin_retrieval_snapshot()
_current, legacy_bases = vector_collection_bases(builtin)
client = _chroma(home)
_fill(client, f"{legacy_bases[0]}_512", ["m1", "m2"])
del client
_confirmed(store, "m1", "m2")
opened: list[str] = []
def open_client(profile):
opened.append(profile.profile_id)
return _reading({"m1", "m2"})(profile)
result = run_startup_migration(
data_dir=home,
runtime_build="21",
sdk_version="2.0.5",
namespace="governed-v2",
open_client=open_client,
)
assert result.state == "cutover"
assert opened == [builtin.profile_id]
assert [item.legacy for item in result.reattached] == [f"{legacy_bases[0]}_512"]
assert result.readback is not None and result.readback.passed
assert store.authority() is MemoryAuthority.SDK
def test_the_builtin_readback_client_resolves_no_secret(tmp_path, monkeypatch):
"""The built-in snapshot has no stored version and no key to look up."""
from one_memory import sdk_migration_verify
from one_memory.hy_sdk_runtime import builtin_retrieval_snapshot
leased: list[tuple[str, object]] = []
class Lease:
client = object()
def close(self) -> None:
leased.append(("closed", None))
def acquire(profile, *, api_key, data_dir):
leased.append((profile.profile_id, api_key))
return Lease()
def no_lookup(*_args, **_kwargs):
raise AssertionError("a key-less profile must not touch the secret store")
monkeypatch.setattr("one_memory.hy_sdk_runtime.acquire_client", acquire)
monkeypatch.setattr(
"one_memory.model_profiles.ModelProfileStore.resolve_snapshot_secret", no_lookup
)
with sdk_migration_verify.runtime_sdk_client(
tmp_path / "missing.sqlite3", tmp_path, builtin_retrieval_snapshot()
) as client:
assert client is Lease.client
assert leased == [("builtin-retrieval", None), ("closed", None)]
def test_vectors_left_by_a_deleted_profile_stop_the_upgrade_by_name(store, monkeypatch):
"""No active profile, and the vectors sit in another profile's space.
The built-in model cannot read them, so the old abort was right for this
one shape; it now says what is actually wrong, before anything changes.
"""
from one_memory.hy_sdk_runtime import vector_collection_bases
from one_memory.model_profiles import ModelProfileSnapshot, ProviderKind
monkeypatch.setattr("one_memory.sdk_migration_runner.read_active_profile", lambda _db: None)
deleted = ModelProfileSnapshot(
profile_id="deleted",
revision=3,
provider_kind=ProviderKind.OPENAI_COMPATIBLE,
base_url="https://models.example/v1",
model="chat",
embedding_provider_kind=ProviderKind.OPENAI_COMPATIBLE,
embedding_base_url="https://models.example/v1",
embedding_model="text-embedding-3-large",
embedding_dimensions=1024,
timeout_seconds=60,
max_concurrency=1,
context_window=None,
has_api_key=True,
)
current, _legacy = vector_collection_bases(deleted)
home = store.database_path.parent
client = _chroma(home)
_fill(client, f"{current}_1024", ["m1"])
del client
_confirmed(store, "m1")
with pytest.raises(DataPlaneMigrationError, match="sdk_migration_model_profile_required"):
with pytest.raises(DataPlaneMigrationError, match="sdk_migration_vector_profile_missing"):
run_startup_migration(
data_dir=store.database_path.parent,
data_dir=home,
runtime_build="21",
sdk_version="2.0.5",
namespace="governed-v2",
open_client=_reading({"m1"}),
)
assert store.authority() is MemoryAuthority.LEGACY
assert not (home / "backups").exists()
def test_confirmed_chats_the_sdk_had_already_merged_do_not_fail_the_upgrade(store, active_profile):
"""#229/#240: 658 confirmed chats, 657 in the store, and the upgrade failed.
A 1.0.4 library can hold confirmed rows whose SDK node had been merged or
deleted before the upgrade; no migration can read those back. The check
holds chat to what the store held before this run and counts the rest.
"""
from one_memory.hy_sdk_runtime import vector_collection_bases
home = store.database_path.parent
stored = [f"chat-{index:03d}" for index in range(657)]
current, _legacy = vector_collection_bases(active_profile)
client = _chroma(home)
_fill(client, f"{current}_512", stored)
del client
_confirmed(store, *stored, "chat-merged-before-upgrade")
result = run_startup_migration(
data_dir=home,
runtime_build="21",
sdk_version="2.0.5",
namespace="governed-v2",
open_client=_reading(set(stored)),
)
assert result.state == "cutover"
assert result.readback is not None
assert result.readback.as_document() == {
"chat_expected": 657,
"chat_readable": 657,
"productivity_expected": 0,
"productivity_readable": 0,
"chat_missing_before_migration": 1,
"passed": True,
}
assert store.authority() is MemoryAuthority.SDK
def test_a_chat_in_the_store_before_the_upgrade_must_still_read_back(store, active_profile):
"""Only what was already gone is excused; losing a stored memory still fails."""
from one_memory.hy_sdk_runtime import vector_collection_bases
from one_memory.sdk_migration_verify import MemoryReadbackFailed
home = store.database_path.parent
current, _legacy = vector_collection_bases(active_profile)
client = _chroma(home)
_fill(client, f"{current}_512", ["m1", "m2"])
del client
_confirmed(store, "m1", "m2", "merged")
with pytest.raises(MemoryReadbackFailed) as caught:
run_startup_migration(
data_dir=home,
runtime_build="21",
sdk_version="2.0.5",
namespace="governed-v2",
open_client=_reading({"m1"}),
)
assert caught.value.report.as_document() == {
"chat_expected": 2,
"chat_readable": 1,
"productivity_expected": 0,
"productivity_readable": 0,
"chat_missing_before_migration": 1,
"passed": False,
}
assert store.authority() is MemoryAuthority.LEGACY
def test_a_build_20_home_gets_its_vectors_back_and_is_checked_against_its_backup(
@@ -1072,7 +1241,13 @@ def test_host_capture_is_fenced_for_the_whole_migration(store):
)
assert result.state == "cutover"
assert fenced == {"bundle": True, "prepare": True, "cutover": True, "cleanup": True}
assert fenced == {
"bundle": True,
"prepare": True,
"vectors": True,
"cutover": True,
"cleanup": True,
}
assert not sdk_migration_capture_fenced(home)
@@ -306,7 +306,7 @@ def test_the_runner_names_the_step_and_sub_step_that_failed(tmp_path, monkeypatc
("step.finished", "preflight.profile_read"),
("step.finished", "preflight.resumable_identity"),
("step.finished", "preflight.legacy_ids_scan"),
("step.finished", "preflight.model_profile_check"),
("step.finished", "preflight.vector_space_check"),
("step.finished", "prepare.recover_abandoned_staging"),
("step.finished", "bundle.ensure"),
("step.finished", "bundle.restore_point"),
@@ -463,11 +463,17 @@ def test_a_run_killed_after_its_restore_point_is_rolled_back_on_the_next_start(h
def test_a_preflight_failure_changes_nothing_and_needs_no_restore(home, monkeypatch):
def unreadable(_data_dir, _profile):
raise DataPlaneMigrationError("sdk_migration_vector_profile_missing")
monkeypatch.setattr("one_memory.sdk_migration_runner.read_active_profile", lambda _db: None)
monkeypatch.setattr(
"one_memory.sdk_migration_runner.vectors_belong_to_another_space", unreadable
)
before = _state(home)
report: dict[str, object] = {}
with pytest.raises(DataPlaneMigrationError, match="sdk_migration_model_profile_required"):
with pytest.raises(DataPlaneMigrationError, match="sdk_migration_vector_profile_missing"):
_run(home, report=report)
assert report == {}
@@ -1,5 +1,6 @@
"""Model-free SDK search remains available without an active write profile."""
import asyncio
from pathlib import Path
from types import SimpleNamespace
@@ -95,3 +96,158 @@ async def test_search_without_profile_uses_public_sdk_search() -> None:
assert options["user_ids"] == ["local"]
assert options["agent_ids"] == []
assert options["scene"] == "normal"
def _activate(profiles, secrets, *, dimensions: int, model: str = "chat"):
from pydantic import SecretStr
from one_memory.model_profiles import ModelCapabilities, ModelProfileInput
profile = profiles.create(
ModelProfileInput(
name=f"embedding-{dimensions}",
provider_kind=ProviderKind.OPENAI_COMPATIBLE,
base_url="https://models.example/v1",
model=model,
embedding_provider_kind=ProviderKind.OPENAI_COMPATIBLE,
embedding_base_url="https://models.example/v1",
embedding_model="qwen3-embedding-4b",
embedding_dimensions=dimensions,
api_key=SecretStr("sk-test"),
),
secrets,
)
profiles.record_capabilities(
profile.profile_id,
expected_revision=profile.revision,
capabilities=ModelCapabilities(
endpoint_reachable=True,
model_available=True,
structured_output=True,
embedding_available=True,
),
secrets=secrets,
)
profiles.activate(profile.profile_id, secrets)
return profiles.snapshot(profile.profile_id, profiles.active_identity()[1])
class _Recorder:
"""Stands in for `acquire_client`: records the collection each client opens."""
def __init__(self, data_dir: Path) -> None:
self.data_dir = data_dir
self.opened: list[tuple[str, int | None, str | None]] = []
self.closed: list[str] = []
def __call__(self, profile, *, api_key, data_dir, http_transport_factory=None):
from one_memory.hy_sdk_runtime import build_sdk_config
config = build_sdk_config(profile, api_key=api_key, data_dir=data_dir)
name = config.vector_store.collection_name
self.opened.append((name, config.vector_store.embedding_dims, api_key))
return SimpleNamespace(
client=SimpleNamespace(collection=name),
close=lambda: self.closed.append(name),
)
@pytest.mark.anyio
async def test_reads_open_the_collection_the_active_profile_writes(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""#242: a 2560-dim profile wrote `memhub_<sha>_2560`, reads opened `_512`."""
from one_memory.hy_sdk_runtime import build_sdk_config
from one_memory.model_profiles import ModelProfileStore
from one_memory.sdk_read_handle import ActiveProfileReadHandle
from one_memory.secrets import InMemorySecretStore
recorder = _Recorder(tmp_path)
monkeypatch.setattr("one_memory.hy_sdk_runtime.acquire_client", recorder)
profiles = ModelProfileStore(tmp_path / "one-memory.sqlite3")
secrets = InMemorySecretStore()
handle = ActiveProfileReadHandle(profiles=profiles, secrets=secrets, data_dir=tmp_path)
builtin = build_sdk_config(builtin_retrieval_snapshot(), api_key=None, data_dir=tmp_path)
assert (await handle.client()).collection == builtin.vector_store.collection_name
written = _activate(profiles, secrets, dimensions=2560)
writes = build_sdk_config(written, api_key="sk-test", data_dir=tmp_path)
async def read() -> str:
return str((await handle.client()).collection)
assert await asyncio.create_task(read()) == writes.vector_store.collection_name
assert recorder.opened[-1] == (writes.vector_store.collection_name, 2560, "sk-test")
assert writes.vector_store.collection_name != builtin.vector_store.collection_name
await handle.close()
@pytest.mark.anyio
async def test_a_profile_change_rebuilds_reads_without_closing_one_in_flight(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from one_memory.model_profiles import ModelProfileStore
from one_memory.sdk_read_handle import ActiveProfileReadHandle
from one_memory.secrets import InMemorySecretStore
recorder = _Recorder(tmp_path)
monkeypatch.setattr("one_memory.hy_sdk_runtime.acquire_client", recorder)
profiles = ModelProfileStore(tmp_path / "one-memory.sqlite3")
secrets = InMemorySecretStore()
handle = ActiveProfileReadHandle(profiles=profiles, secrets=secrets, data_dir=tmp_path)
_activate(profiles, secrets, dimensions=1024)
holding = asyncio.Event()
finish = asyncio.Event()
async def slow_read() -> str:
client = await handle.client()
holding.set()
await finish.wait()
return str(client.collection)
in_flight = asyncio.create_task(slow_read())
await holding.wait()
[(old, _, _)] = recorder.opened
_activate(profiles, secrets, dimensions=2560, model="chat-2")
async def read() -> str:
return str((await handle.client()).collection)
new = await asyncio.create_task(read())
assert new != old
# The read that started on the old space still has its client.
assert recorder.closed == []
finish.set()
assert await in_flight == old
for _ in range(5):
await asyncio.sleep(0)
assert recorder.closed == [old]
await handle.close()
assert recorder.closed == [old, new]
def test_every_api_read_shares_the_active_profile_handle(tmp_path: Path) -> None:
"""Library, search, recall, graph, profile, raw, ask and context all read
through `_sdk_client_handle`, so it must be the active-profile handle."""
from one_memory.api import _sdk_client_handle
from one_memory.config import Settings
from one_memory.model_profiles import ModelProfileStore
from one_memory.sdk_read_handle import ActiveProfileReadHandle
from one_memory.secrets import InMemorySecretStore
state = SimpleNamespace(
sdk_client_handle=None,
model_profile_store=ModelProfileStore(tmp_path / "one-memory.sqlite3"),
secret_store=InMemorySecretStore(),
settings=Settings(_env_file=None, data_dir=tmp_path),
)
app = SimpleNamespace(state=state)
handle = _sdk_client_handle(app) # type: ignore[arg-type]
assert isinstance(handle, ActiveProfileReadHandle)
assert _sdk_client_handle(app) is handle is state.sdk_client_handle # type: ignore[arg-type]
@@ -133,6 +133,7 @@ const SAFE_STABLE_RUNTIME_ERROR_CODES = new Set([
"sdk_migration_preflight_failed",
"sdk_migration_memory_unreadable",
"sdk_migration_model_profile_required",
"sdk_migration_vector_profile_missing",
"sdk_migration_model_secret_unavailable",
"sdk_migration_secure_storage_unavailable",
"sdk_migration_disk_space_insufficient",
@@ -219,6 +220,8 @@ export const STABLE_RUNTIME_USER_MESSAGES: Record<string, string> = {
"升级前核对发现,新版本还读不到你的全部记忆,所以没有切换数据源,旧数据保持原样可用。请把这个提示反馈给我们;原有记忆数据不会丢失。",
sdk_migration_model_profile_required:
"升级记忆数据格式需要先有可用的模型配置,用来核对新版本能否读回你的记忆。请在旧版本中完成模型配置后重启;你的记忆数据不会受到影响。",
sdk_migration_vector_profile_missing:
"你的记忆是用一个已删除的模型配置生成向量的,当前没有可用的模型配置能读回它们,所以没有升级。请在旧版本中重新添加那个模型配置并启用后重启;你的记忆数据不会受到影响。",
sdk_migration_model_secret_unavailable:
"模型密钥读不到(可能被系统钥匙串拒绝),无法核对新版本能否读回你的记忆。请允许 MemHub 访问钥匙串后重新启动;你的记忆数据不会受到影响。",
sdk_migration_secure_storage_unavailable:
@@ -480,6 +480,30 @@ describe("ModelSettings", () => {
});
});
it("writes the prefilled embedding endpoint instead of leaving it to follow generation", async () => {
profiles = [
{
...profile,
embedding_provider_kind: "openai_compatible",
embedding_model: "vector-old",
embedding_base_url: null,
},
];
render(<ModelSettings />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
await userEvent.clear(screen.getByLabelText("向量模型名"));
await userEvent.type(screen.getByLabelText("向量模型名"), "vector-new");
await userEvent.click(
screen.getByRole("button", { name: "高级:自行配置向量模型" }),
);
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(update).toHaveBeenCalledWith(profile.profile_id, {
expected_revision: 1,
embedding_model: "vector-new",
embedding_base_url: profile.base_url,
});
});
it("explicitly switches an edited remote embedding to builtin without retaining its dimensions", async () => {
profiles = [
{
+3 -4
View File
@@ -631,10 +631,9 @@ export function ModelSettings({
patch.embedding_model = embeddingModel;
if (embeddingProvider !== editing.embedding_provider_kind)
patch.embedding_provider_kind = embeddingProvider;
if (
embeddingBaseUrl !==
(editing.embedding_base_url ?? editing.base_url ?? "")
)
// Against the stored value only: the field is prefilled with the
// generation endpoint, and Core no longer falls back to it.
if (embeddingBaseUrl !== (editing.embedding_base_url ?? ""))
patch.embedding_base_url = embeddingBaseUrl;
} else if (
embeddingModeChanged &&
@@ -173,10 +173,15 @@ export function OnboardingModelController({
draft.embedding_mode === "local"
? "bge-small-zh-v1.5"
: draft.embedding_model.trim(),
// Core no longer lets the embedding endpoint follow the generation
// one, so an empty field is written as the endpoint it means.
embedding_base_url:
draft.embedding_mode === "local"
? null
: draft.embedding_base_url.trim() || null,
: draft.embedding_base_url.trim() ||
(draft.embedding_provider_kind === "ollama"
? "http://127.0.0.1:11434"
: draft.base_url.trim()),
embedding_dimensions: draft.embedding_mode === "local" ? 512 : null,
};
}
@@ -77,25 +77,33 @@ then:
2. **prepare**: applies the management schema migrations, discards SDK staging
left by builds through 19 (§5) and makes legacy queue work self-contained
(§4);
3. **vectors**: for the active embedding profile, renames every
3. **vectors**: for the active embedding profile (the built-in model when no
profile is active), renames every
`one_memory_<old hash>…` collection to the current `memhub_<hash>…` name
through Chroma's public client. A current collection that already holds
writes is merged in first (newer record wins on a shared id); counts are
checked after each step;
4. **verify**: opens the public SDK client exactly as the Runtime will and reads
the legacy confirmed memory ids back (`list_memories`, then `get`). Every
chat memory must read back. Coding/Working may be fewer, because the SDK's
Coding reconciler deletes experiences it retires, but not zero when the
legacy mirror has some. Otherwise the run stops with
`sdk_migration_memory_unreadable` and the counts, before anything
irreversible; legacy authority and tables stay intact;
confirmed chat memory that was in the vector store before this run (any
collection, read before step 3) must read back. A confirmed chat the SDK had
already merged or deleted before the upgrade cannot, and is only counted
(`chat_missing_before_migration`); if none of them is in the store, none is
excused. Coding/Working may be fewer, because the SDK's Coding reconciler
deletes experiences it retires, but not zero when the legacy mirror has
some. Otherwise the run stops with `sdk_migration_memory_unreadable` and
the counts, before anything irreversible; legacy authority and tables stay
intact;
5. **cutover**: re-verifies the bundle, repeats §4, commits SDK authority;
6. **cleanup**: drops the legacy tables (including the `evidence_search`
index) and runs `VACUUM` (skipped if the disk is too full).
A home with legacy memories but no active model profile is not migrated
(`sdk_migration_model_profile_required`): its collections can be neither named
nor read back.
A home with no active model profile has no generation model, but its vectors
are the built-in embedding model's, the same space the Runtime reads with; it
migrates through that space and no key is resolved. Only when its legacy
memories have vectors solely under another embedding space (a profile since
deleted) is it not migrated (`sdk_migration_vector_profile_missing`, in the
preflight, before anything changes).
A home already on SDK authority still reports `required` while legacy-named
vector collections exist for its profile (build 20 homes). `sdk-migrate` then