diff --git a/desktop/package-lock.json b/desktop/package-lock.json index c412eef..a300972 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "hugagent-desktop", - "version": "0.2.15", + "version": "0.2.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hugagent-desktop", - "version": "0.2.15", + "version": "0.2.16", "devDependencies": { "@tauri-apps/cli": "^2" } diff --git a/desktop/package.json b/desktop/package.json index ea4159d..777727e 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "hugagent-desktop", - "version": "0.2.15", + "version": "0.2.16", "private": true, "description": "HugAgentOS 桌面客户端(远程连接 + Windows/macOS/Linux 离线本机服务)", "scripts": { diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index f81a768..c80e0f8 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1687,7 +1687,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hugagent-desktop" -version = "0.2.15" +version = "0.2.16" dependencies = [ "axum", "bytes", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index ac50c38..aaddd4c 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hugagent-desktop" -version = "0.2.15" +version = "0.2.16" description = "HugAgentOS桌面客户端" edition = "2021" rust-version = "1.77" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 25002d1..1ff1499 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "HugAgentOS", - "version": "0.2.15", + "version": "0.2.16", "identifier": "com.hugagent.desktop", "build": { "frontendDist": "../../src/frontend/dist", diff --git a/src/backend/api/routes/v1/models.py b/src/backend/api/routes/v1/models.py index 91de957..9494fc6 100644 --- a/src/backend/api/routes/v1/models.py +++ b/src/backend/api/routes/v1/models.py @@ -118,6 +118,25 @@ class ImportRequest(BaseModel): # ── Helpers ─────────────────────────────────────────────────────────────────── +def _invalidate_model_caches() -> None: + """Single invalidation point for model-config changes: the resolve cache plus + every runtime singleton built from it. + + The mem0 singleton captures the memory/embedding config at init time; clearing + the resolve cache alone leaves memory running on stale credentials (a first + init against the placeholder key means every later write fails 401). The reset + is lazy — the next memory read/write rebuilds against the fresh config — so it + is cheap enough to apply on every change without filtering by role. + """ + ModelConfigService.get_instance().invalidate_cache() + try: + from core.memory import service as memory_service + + memory_service.reset_runtime() + except Exception as exc: # a failed runtime reset must not block saving the config + logger.warning("[models] memory runtime reset failed (ignored): %s", exc) + + def _mask_api_key(key: str) -> str: if not key or len(key) <= 8: return "****" @@ -489,7 +508,7 @@ async def create_provider_endpoint( currency=body.currency, display_name=body.display_name, ) - ModelConfigService.get_instance().invalidate_cache() + _invalidate_model_caches() return success_response(data=_provider_to_dict(provider, _get_pricing(db, provider.model_name))) @@ -547,7 +566,7 @@ async def update_provider_endpoint( display_name=provider.display_name, **price_fields, ) - ModelConfigService.get_instance().invalidate_cache() + _invalidate_model_caches() return success_response(data=_provider_to_dict(provider, _get_pricing(db, provider.model_name))) @@ -566,7 +585,7 @@ async def delete_provider_endpoint( ) if not delete_provider(db, provider_id): raise HTTPException(status_code=404, detail="Provider not found") - ModelConfigService.get_instance().invalidate_cache() + _invalidate_model_caches() return success_response(data={"deleted": provider_id}) @@ -652,7 +671,7 @@ async def assign_role_endpoint( if not assign_role(db, role_key, body.provider_id): raise HTTPException(status_code=400, detail="Assignment failed") - ModelConfigService.get_instance().invalidate_cache() + _invalidate_model_caches() return success_response(data={"role_key": role_key, "provider_id": body.provider_id}) @@ -666,7 +685,7 @@ async def unassign_role_endpoint( if role_key not in ROLE_DEFINITIONS: raise HTTPException(status_code=404, detail=f"Unknown role: {role_key}") unassign_role(db, role_key) - ModelConfigService.get_instance().invalidate_cache() + _invalidate_model_caches() return success_response(data={"role_key": role_key, "provider_id": None}) @@ -715,5 +734,5 @@ async def import_endpoint( ): """导入模型配置(供应商 + 角色分配)。仅限管理员(CONFIG_TOKEN / can_system_config,同 export);overwrite=True 时覆盖同名条目,导入后刷新模型配置缓存。""" result = import_all(db, body.model_dump(), overwrite=body.overwrite) - ModelConfigService.get_instance().invalidate_cache() + _invalidate_model_caches() return success_response(data=result) diff --git a/src/backend/core/db/models/__init__.py b/src/backend/core/db/models/__init__.py index 2d42f6b..3ecfd74 100644 --- a/src/backend/core/db/models/__init__.py +++ b/src/backend/core/db/models/__init__.py @@ -34,6 +34,18 @@ MessageFeedback, ) from core.db.models.config import ModelProvider, ModelRoleAssignment, SystemConfig +from core.db.models.evolution import ( + EvolutionAgentProfile, + EvolutionCandidate, + EvolutionCreditDecision, + EvolutionEpisode, + EvolutionEvaluation, + EvolutionEvidencePack, + EvolutionMemoryOp, + EvolutionPromotionLink, + EvolutionRelease, + EvolutionTraceEvent, +) from core.db.models.identity import ( ChannelConnection, DingTalkConnection, @@ -46,7 +58,7 @@ ) from core.db.models.knowledge import CatalogOverride, KBChunk, KBDocument, KBSpace from core.db.models.logs import SkillCallLog, SubAgentCallLog, ToolCallLog -from core.db.models.memory import MemorySanitizerRule, ProfileMemory +from core.db.models.memory import MemoryRefShadow, MemorySanitizerRule, ProfileMemory from core.db.models.ontology import ( OntologyDraft, OntologyEnforcementEvent, diff --git a/src/backend/core/memory/audit.py b/src/backend/core/memory/audit.py index 1bd8d1f..2e3c304 100644 --- a/src/backend/core/memory/audit.py +++ b/src/backend/core/memory/audit.py @@ -1,23 +1,34 @@ -"""记忆审计旁路 —— 社区版 no-op stub。 - -记忆审计(存 hash 不存原文、链路追溯)属商业版合规能力;社区版保持 -同名接口但不落任何审计数据,调用方(L1 画像 / 抽取写入器)零改动。 +"""Memory audit side-channel — community-edition no-op stub. + +Memory auditing (hash-only storage, trace chains) is a commercial-edition +compliance capability; the community edition keeps the same module surface but +records nothing, so callers (the L1 profile writer, the extraction pipeline) +need zero changes. + +The stubs must stay *call-compatible* with the EE implementation, whose real +signatures are ``record(ctx, action, layer, *, memory_id=None, content=None, +reason=None)`` etc. — the first three are positional. Narrowing the stub +signature makes positional calls raise TypeError, and that raise lands *after* +the business transaction has committed, turning a successful write into a +reported failure (bitten in the 0.2.15 desktop local build: the L1 preference +was persisted, yet the UI never showed the write card). Hence ``*args`` / +``**kwargs`` catch-alls. """ from __future__ import annotations -from typing import Any, Iterable +from typing import Any -async def record(ctx: Any = None, **kwargs: Any) -> None: +async def record(*args: Any, **kwargs: Any) -> None: return None -async def record_batch(ctx: Any = None, items: Iterable[Any] | None = None, **kwargs: Any) -> None: +async def record_batch(*args: Any, **kwargs: Any) -> None: return None -def record_sync(ctx: Any = None, **kwargs: Any) -> None: +def record_sync(*args: Any, **kwargs: Any) -> None: return None diff --git a/src/backend/core/memory/service.py b/src/backend/core/memory/service.py index 38ddb12..061a1cd 100644 --- a/src/backend/core/memory/service.py +++ b/src/backend/core/memory/service.py @@ -156,6 +156,201 @@ def _build_mem0_config() -> dict: return config +def _apply_probed_embed_dims(cfg: dict) -> None: + """Correct the collection dimension with one real /embeddings call. + + The embed call is patched to drop the ``dimensions`` parameter (qwen3-style + models reject matryoshka), so stored vectors always come back at the model's + *native* width, while the collection is created from configuration (default + 1024). When the two disagree the collection is born broken (qwen3-embedding-8b + actually returns 4096) and every subsequent insert fails on a dim mismatch. + A probe failure (network / credentials) never blocks init — the configured + value stays in effect. + """ + emb = cfg["embedder"]["config"] + vs = cfg["vector_store"]["config"] + try: + from openai import OpenAI + + client = OpenAI( + api_key=emb["api_key"], + base_url=emb["openai_base_url"], + timeout=8, + max_retries=0, + ) + probed = len( + client.embeddings.create(input=["dimension probe"], model=emb["model"]).data[0].embedding + ) + except Exception as exc: + logger.warning( + "[MemoryService] embed dims probe failed, keeping configured %s: %s", + vs["embedding_model_dims"], + exc, + ) + return + if probed and probed != vs["embedding_model_dims"]: + logger.info( + "[MemoryService] embedding model returns %d dims (configured %d), using the probed value", + probed, + vs["embedding_model_dims"], + ) + vs["embedding_model_dims"] = probed + + +# Refuse to auto-migrate beyond this many rows: a paginated Milvus query tops out +# at offset+limit 16384, and re-embedding a huge collection inside init would +# stall every caller behind the singleton lock. +_MIGRATION_MAX_ROWS = 10_000 + + +def _reconcile_vector_collection(cfg: dict) -> Optional[list]: + """Reconcile an existing Milvus collection whose dim no longer matches. + + Two ways to get here: the collection was bootstrapped at a wrong default + width (writes never succeeded, so it is empty), or the user switched to an + embedding model with a different native width (the data is real and must + follow the new model). + + - empty collection → drop it; mem0 recreates it at the right width. + - non-empty → re-embed every stored text with the *new* embedder first; + only when all of them succeed is the old collection dropped. Returns the + re-embedded rows for `_replay_migrated_rows` to insert after mem0 has + recreated the collection. If re-embedding fails the old data stays put. + + Reconcile failures only warn — init proceeds exactly as before. + """ + vs = cfg["vector_store"]["config"] + client = None + try: + from pymilvus import MilvusClient + + client = MilvusClient(uri=vs["url"], token=vs.get("token") or "") + name = vs["collection_name"] + if not client.has_collection(name): + return None + desc = client.describe_collection(name) + existing = 0 + for field in desc.get("fields", []): + dim = (field.get("params") or {}).get("dim") + if dim: + existing = int(dim) + break + desired = int(vs["embedding_model_dims"]) + if not existing or existing == desired: + return None + rows = int(client.get_collection_stats(name).get("row_count", 0) or 0) + if rows == 0: + client.drop_collection(name) + logger.warning( + "[MemoryService] empty collection %s at %d dims != target %d — dropped for rebuild", + name, + existing, + desired, + ) + return None + if rows > _MIGRATION_MAX_ROWS: + logger.error( + "[MemoryService] collection %s holds %d rows at %d dims != target %d — " + "too large for automatic migration, migrate it manually", + name, + rows, + existing, + desired, + ) + return None + replay = _reembed_rows(client, name, cfg) + if replay is None: + # Old data is untouched; writes keep failing until the new embedder + # is reachable, at which point the next init retries the migration. + logger.error( + "[MemoryService] collection %s needs migration from %d to %d dims but " + "re-embedding failed — keeping the old collection", + name, + existing, + desired, + ) + return None + client.drop_collection(name) + logger.warning( + "[MemoryService] migrating collection %s: %d memories re-embedded from %d to %d dims", + name, + len(replay), + existing, + desired, + ) + return replay + except Exception as exc: + logger.warning("[MemoryService] collection reconcile failed (ignored): %s", exc) + return None + finally: + if client is not None: + try: + client.close() + except Exception: + pass + + +def _reembed_rows(client, name: str, cfg: dict) -> Optional[list]: + """Fetch every stored payload and embed its text with the new embedder. + + Every embedding must succeed *before* the caller drops the old collection — + a drop after a half-done migration would destroy memories whenever the new + model happens to be unreachable. + """ + emb = cfg["embedder"]["config"] + try: + from openai import OpenAI + + oai = OpenAI( + api_key=emb["api_key"], + base_url=emb["openai_base_url"], + timeout=30, + max_retries=1, + ) + out = [] + offset = 0 + page = 500 + while True: + batch = client.query( + collection_name=name, + filter='id != ""', + output_fields=["id", "metadata"], + offset=offset, + limit=page, + ) + if not batch: + break + for row in batch: + payload = row.get("metadata") or {} + text = payload.get("data") + if not text: + continue # e.g. malformed legacy rows; nothing to re-embed + vector = ( + oai.embeddings.create(input=[text], model=emb["model"]).data[0].embedding + ) + out.append({"id": row.get("id"), "payload": payload, "vector": vector}) + if len(batch) < page: + break + offset += page + return out + except Exception as exc: + logger.error("[MemoryService] re-embedding for migration failed: %s", exc) + return None + + +def _replay_migrated_rows(instance, rows: list) -> None: + """Insert re-embedded rows into the collection mem0 just recreated.""" + try: + instance.vector_store.insert( + ids=[row["id"] for row in rows], + vectors=[row["vector"] for row in rows], + payloads=[row["payload"] for row in rows], + ) + logger.info("[MemoryService] migration replay done: %d memories restored", len(rows)) + except Exception as exc: + logger.error("[MemoryService] migration replay failed (%d rows): %s", len(rows), exc) + + def _get_memory() -> Optional[object]: """Thread-safe lazy initialization: cache the instance on success; on failure allow retry next time.""" global _memory_instance, _memory_init_failed @@ -177,10 +372,16 @@ def _get_memory() -> Optional[object]: from mem0 import Memory cfg = _build_mem0_config() + _apply_probed_embed_dims(cfg) + replay_rows = _reconcile_vector_collection(cfg) logger.info( "[MemoryService] 初始化 mem0.Memory (graph=%s)", settings.memory.graph_enabled ) _memory_instance = Memory.from_config(cfg) + if replay_rows: + # Embedding-model switch: refill the freshly created collection + # with the rows re-embedded during reconciliation. + _replay_migrated_rows(_memory_instance, replay_rows) _memory_init_failed = False return _memory_instance except Exception as exc: @@ -201,12 +402,35 @@ def _reset_memory() -> None: logger.info("[MemoryService] 已重置 mem0 实例,下次调用将重新初始化") +def reset_runtime() -> None: + """Invalidation entry point for model-config changes (called by the config routes). + + The mem0 singleton is built from the model config *as of init time*; clearing + the ModelConfigService cache alone never rebuilds an existing instance — if the + first init ran with the placeholder key, every later write keeps failing 401. + """ + _reset_memory() + + def _is_connection_error(exc: Exception) -> bool: """Check if an exception indicates a broken Milvus/gRPC connection.""" msg = str(exc).lower() return any(kw in msg for kw in ("closed channel", "connection refused", "unavailable", "grpc")) +def _is_auth_error(exc: Exception) -> bool: + """Credential-style failures: the instance was most likely built from a stale or + placeholder config, so a reset followed by a rebuild against the current config + self-heals. Kept separate from `_is_connection_error` on purpose — auth errors + must not count against the Milvus circuit breaker. + """ + msg = str(exc).lower() + return any( + kw in msg + for kw in ("invalid_api_key", "incorrect api key", "unauthorized", "error code: 401", "401 unauthorized") + ) + + async def retrieve_memories( user_id: str, query: str, @@ -381,7 +605,7 @@ async def _do_search() -> MemoryRetrievalResult: _record_retrieval_refs(result, user_id=user_id, workspace_id=workspace_id) return result except Exception as exc: - if attempt == 0 and _is_connection_error(exc): + if attempt == 0 and (_is_connection_error(exc) or _is_auth_error(exc)): logger.warning("[MemoryService] Milvus 连接断开,重试: %s", exc) _reset_memory() continue @@ -652,48 +876,59 @@ async def save_procedure_entry( mem0_user_id = ctx.effective_scope_user_id messages = [{"role": "assistant", "content": content}] - try: - result = await loop.run_in_executor( - None, - # ``infer=False`` stores this text verbatim. - # - # By default mem0 runs its *own* LLM extraction over whatever it is - # handed and decides for itself whether to keep anything. Our - # procedural extractor has already done exactly that work — with a - # prompt built for procedures rather than mem0's generic - # fact-finding one — so leaving inference on means paying for a - # second model call whose only power is to disagree. And it does - # disagree: handed a distilled rule it frequently returns no - # operations at all, which reaches us as a successful write of - # nothing. That failure is invisible by construction — the card - # shows no memory, the log shows no error, and the user is told the - # turn had nothing worth remembering. - lambda: memory.add( - messages, - user_id=mem0_user_id, - metadata=metadata, - infer=False, - # Native expiry: an expired entry stops being recalled without - # waiting for the sweeper to physically remove it. ttl_days - # stays in metadata as the display/extension source of truth. - expiration_date=_expiration_from_ttl(ttl_days), - ), - ) - if milvus_breaker is not None: - milvus_breaker.record_success() - memory_id = _added_memory_id(result) - logger.debug( - "[MemoryService] procedure saved user=%s ws=%s id=%s", - ctx.user_id, - ctx.workspace_id, - memory_id, - ) - return memory_id - except Exception as exc: - logger.warning("[MemoryService] save_procedure_entry failed: %s", exc) - if milvus_breaker is not None and _is_connection_error(exc): - milvus_breaker.record_failure() - return None + for attempt in range(2): + try: + result = await loop.run_in_executor( + None, + # ``infer=False`` stores this text verbatim. + # + # By default mem0 runs its *own* LLM extraction over whatever it is + # handed and decides for itself whether to keep anything. Our + # procedural extractor has already done exactly that work — with a + # prompt built for procedures rather than mem0's generic + # fact-finding one — so leaving inference on means paying for a + # second model call whose only power is to disagree. And it does + # disagree: handed a distilled rule it frequently returns no + # operations at all, which reaches us as a successful write of + # nothing. That failure is invisible by construction — the card + # shows no memory, the log shows no error, and the user is told the + # turn had nothing worth remembering. + lambda: memory.add( + messages, + user_id=mem0_user_id, + metadata=metadata, + infer=False, + # Native expiry: an expired entry stops being recalled without + # waiting for the sweeper to physically remove it. ttl_days + # stays in metadata as the display/extension source of truth. + expiration_date=_expiration_from_ttl(ttl_days), + ), + ) + if milvus_breaker is not None: + milvus_breaker.record_success() + memory_id = _added_memory_id(result) + logger.debug( + "[MemoryService] procedure saved user=%s ws=%s id=%s", + ctx.user_id, + ctx.workspace_id, + memory_id, + ) + return memory_id + except Exception as exc: + # Both a broken connection and stale credentials point at an instance + # built from an outdated config: reset, rebuild against the current + # config and retry once. Auth errors stay out of the Milvus breaker. + if attempt == 0 and (_is_connection_error(exc) or _is_auth_error(exc)): + logger.warning("[MemoryService] mem0 instance looks stale, resetting for retry: %s", exc) + _reset_memory() + memory = await loop.run_in_executor(None, _get_memory) + if memory is not None: + continue + logger.warning("[MemoryService] save_procedure_entry failed: %s", exc) + if milvus_breaker is not None and _is_connection_error(exc): + milvus_breaker.record_failure() + return None + return None def _added_memory_id(result) -> Optional[str]: @@ -766,7 +1001,7 @@ async def save_conversation(user_id: str, user_message: str, assistant_message: logger.info("[MemoryService] 用户 %s 的记忆已保存, result=%s", user_id, result) return except Exception as exc: - if attempt == 0 and _is_connection_error(exc): + if attempt == 0 and (_is_connection_error(exc) or _is_auth_error(exc)): logger.warning("[MemoryService] Milvus 连接断开,正在重置并重试: %s", exc) _reset_memory() continue @@ -809,7 +1044,7 @@ async def get_all_memories( return result return [] except Exception as exc: - if attempt == 0 and _is_connection_error(exc): + if attempt == 0 and (_is_connection_error(exc) or _is_auth_error(exc)): logger.warning("[MemoryService] Milvus 连接断开,正在重置并重试: %s", exc) _reset_memory() continue @@ -837,7 +1072,7 @@ async def update_memory(memory_id: str, content: str) -> bool: await loop.run_in_executor(None, lambda: memory.update(memory_id, content.strip())) return True except Exception as exc: - if attempt == 0 and _is_connection_error(exc): + if attempt == 0 and (_is_connection_error(exc) or _is_auth_error(exc)): logger.warning("[MemoryService] Milvus 连接断开,正在重置并重试: %s", exc) _reset_memory() continue @@ -857,7 +1092,7 @@ async def delete_memory(memory_id: str) -> bool: await loop.run_in_executor(None, lambda: memory.delete(memory_id)) return True except Exception as exc: - if attempt == 0 and _is_connection_error(exc): + if attempt == 0 and (_is_connection_error(exc) or _is_auth_error(exc)): logger.warning("[MemoryService] Milvus 连接断开,正在重置并重试: %s", exc) _reset_memory() continue @@ -879,7 +1114,7 @@ async def delete_all_memories(user_id: str) -> bool: await loop.run_in_executor(None, lambda: memory.delete_all(user_id=user_id)) return True except Exception as exc: - if attempt == 0 and _is_connection_error(exc): + if attempt == 0 and (_is_connection_error(exc) or _is_auth_error(exc)): logger.warning("[MemoryService] Milvus 连接断开,正在重置并重试: %s", exc) _reset_memory() continue diff --git a/src/backend/tests/memory/test_ce_overlay_memory_contracts.py b/src/backend/tests/memory/test_ce_overlay_memory_contracts.py new file mode 100644 index 0000000..51e1dbf --- /dev/null +++ b/src/backend/tests/memory/test_ce_overlay_memory_contracts.py @@ -0,0 +1,78 @@ +"""Contract regressions for the CE overlay's memory-related files. + +Two bugs shipped in the 0.2.15 desktop local build: +1. The CE audit stub narrowed its signature to ``(ctx=None, **kwargs)`` while + callers use the real implementation's positional style + ``record_sync(ctx, action, layer, ...)`` — the TypeError fired *after* the + business transaction committed, reporting a successful L1 write as failed. +2. The CE models facade skipped the evolution models and MemoryRefShadow, so + they never entered Base.metadata and the local SQLite bootstrap could not + create their tables ("no such table" at runtime). + +Both files live under ce/overlay/ (they replace same-named files when the CE +tree is derived); these tests load them by path from the FULL tree and are +skipped in a derived CE tree where ce/ does not exist. +""" + +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +_OVERLAY = Path(__file__).resolve().parents[4] / "ce" / "overlay" / "src" / "backend" + +pytestmark = pytest.mark.skipif( + not _OVERLAY.is_dir(), reason="ce/overlay is absent (derived CE tree)" +) + + +def _load(path: Path, name: str): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + try: + spec.loader.exec_module(module) + finally: + sys.modules.pop(name, None) + return module + + +def test_ce_audit_stub_accepts_positional_calls_like_the_real_impl(): + stub = _load(_OVERLAY / "core" / "memory" / "audit.py", "_ce_audit_stub") + ctx = SimpleNamespace(user_id="u1", workspace_id=None) + + # Same positional call style as core/memory/profile.py::_audit_sync_safe. + stub.record_sync(ctx, "write", "L1", content="v", reason=None) + stub.record_sync(ctx, "update", "L1", content="v") + + +@pytest.mark.asyncio +async def test_ce_audit_stub_async_variants_accept_positional_calls(): + stub = _load(_OVERLAY / "core" / "memory" / "audit.py", "_ce_audit_stub_async") + ctx = SimpleNamespace(user_id="u1", workspace_id=None) + + await stub.record(ctx, "write", "L1", reason="r") + await stub.record_batch(ctx, [{"action": "write", "layer": "L2"}]) + + +def test_ce_models_facade_exports_evolution_and_ref_shadow_tables(): + src = (_OVERLAY / "core" / "db" / "models" / "__init__.py").read_text(encoding="utf-8") + required = [ + "EvolutionAgentProfile", + "EvolutionCandidate", + "EvolutionCreditDecision", + "EvolutionEpisode", + "EvolutionEvaluation", + "EvolutionEvidencePack", + "EvolutionMemoryOp", + "EvolutionPromotionLink", + "EvolutionRelease", + "EvolutionTraceEvent", + "MemoryRefShadow", + ] + missing = [name for name in required if name not in src] + # A model missing from the facade never enters Base.metadata, so the local + # bootstrap (create_all / reconcile) cannot create its table. + assert not missing, f"CE models facade is missing imports: {missing}"