Compare commits

...
Author SHA1 Message Date
alvinfeiandClaude Opus 5.5 b3f13e130f docs: MemHub 1.0.5 news, build 35, macOS arm64 only
The README news line announces v1.0.5 briefly; details live in the release
notes. The release facts said build 14 and promised a macOS Intel build that
this version does not ship. CHANGELOG's Unreleased section becomes MemHub
1.0.5.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 21:58:28 +08:00
kristinhe1111 a621262fac Merge pull request #222 from Tencent-Hunyuan/fix/memory-detail-structure-and-stability
fix(memhub): stabilize memory detail navigation
2026-10-28 20:44:13 +08:00
kristinhe e027635d3f Merge remote-tracking branch 'origin/main' into fix/memory-detail-structure-and-stability
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	memhub/apps/web/src/MemoryMap.test.tsx
2026-10-28 20:36:06 +08:00
feitianxiang 4cb33b7108 Merge pull request #217 from Tencent-Hunyuan/fix/memory-source-filter-sync
fix(memhub): 同步记忆筛选范围并优化首次启动状态
2026-10-28 20:32:24 +08:00
alvinfeiandClaude Opus 5.5 b50c9f33eb fix(memhub): give the map its own graph-loading state
The map hid the previous scope's graph by passing stale into the library
view, which also blanked the list status, pagination and empty states.
A separate graphLoading flag now hides the old graph and shows
"正在读取记忆地图…" while stale keeps its original meaning and text.
The client-side revoked-source graph filter is dropped to match Core.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 20:24:39 +08:00
alvinfeiandClaude Opus 5.5 545b7d38bf fix(memhub): keep revoked-source memories on the memory map
Memories already distilled from a revoked source stay visible, so the
graph endpoint no longer checks revocation before or after the SDK read,
no longer filters memories by revoked source and reports the SDK total
again. The map and the list now agree. The scene="all" and
statuses=["active"] scope stays.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 20:24:39 +08:00
feitianxiang 5f78d67363 Merge pull request #221 from Tencent-Hunyuan/fix/linux-lifecycle-queue-check
fix(memhub): Linux lifecycle checks the write queue, not recall without a model
2026-10-28 20:17:45 +08:00
alvinfei 33d7713d4e Merge remote-tracking branch 'github/main' into fix/memory-source-filter-sync 2026-10-28 20:14:52 +08:00
kristinheandCursor 68a4b546f9 fix(memhub): stabilize memory detail navigation
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 20:11:12 +08:00
kristinhe cbcc50f35e Merge remote-tracking branch 'origin/main' into fix/memory-source-filter-sync
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	memhub/apps/web/src/MemorySpace.tsx
2026-10-28 20:09:35 +08:00
alvinfeiandClaude Opus 5.5 528d2c9fd7 fix(memhub): Linux lifecycle checks the write queue, not recall without a model
The lifecycle verification imported a conversation on a runner with no
generation model and then expected recall to find it. Under SDK authority
nothing reaches the SDK, not even its raw layer, until a model is active,
so the check could never pass and blocked the Linux release archive.

It now asserts what the runner can prove: the imported and appended turns
reach Core's durable write queue, and survive an idempotent upgrade.
docs/TODO-raw-recall-without-model.md records the product gap.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 20:05:44 +08:00
kristinhe1111 0eb9a2844e Merge pull request #216 from Tencent-Hunyuan/fix/onboarding-language-menu-clipping
fix(memhub): keep option menus inside viewport
2026-10-28 20:01:37 +08:00
feitianxiang 8e56bf7a37 Merge pull request #218 from Tencent-Hunyuan/fix/sdk-client-race-and-unknown-jobs
fix(memhub): serialize SDK client startup, log failure frames, settle pre-upgrade unknown writes
2026-10-28 19:49:24 +08:00
alvinfeiandClaude Opus 5.5 352fffb3da fix(memhub): serialize SDK client startup, log failure frames, settle pre-upgrade unknown writes
- hy_sdk_runtime: construct one HyMemoryClient at a time per home. Two
  first-time Chroma opens on a fresh vector dir race Chroma's unlocked
  System cache; on a clean install the drain's and the UI's clients both
  failed and the first write job was lost as unknown.
- sdk_write_processor: a client that fails to construct raises
  hy_memory_client_unavailable before HY is invoked, which parks the job
  (waiting_for_model) instead of leaving it unknown.
- sdk_write_coordinator: a retired profile's handle closes only after its
  last in-flight use (defensive; a profile revision change used to close
  the client under a running write).
- durable_queue: durable_write_job_failed/parked log the exception chain as
  file:line:function frames, never the message.
- sdk_migration_runner: the cutover settles pre-cutover unknown (and
  invoked running) write jobs as cancelled/abandoned_at_cutover and counts
  them as abandoned.unknown_write_jobs; never retried.
- durable_queue/api: retry-unknown and the reconcile helpers no longer
  assume the dropped legacy task tables; the endpoint uses the startup
  queue so it does not recreate memory_write_job_evidence.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 19:39:13 +08:00
kristinheandCursor 5103492762 fix(memhub): harden memory overview recovery
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 19:26:01 +08:00
kristinheandCursor 082aeee1f2 fix(memhub): align graph scope and startup states
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 19:16:48 +08:00
34 changed files with 1925 additions and 318 deletions

No files matched your search

+6
View File
@@ -4,6 +4,12 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
## MemHub 1.0.5 — 2026-10-28
Build 35, on `hy-memory==2.0.8`. Linux x64/arm64 Runtime and macOS arm64
App and Runtime. Upgrading from 1.0.4 migrates the existing library to SDK
authority on first launch.
- MemHub turns off the HY pipeline log and trace at every diagnostic level
(hy-memory 2.0.8 `MEMORY_PIPELINE_LOG_ENABLED=false`,
`MEMORY_PIPELINE_TRACE_ENABLED=false`), so the per-step JSONL files under
+1
View File
@@ -33,6 +33,7 @@ English | [中文](README.zh.md)
## Latest News
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** quantized **FP8** (**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**) and **GGUF** (**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**) versions of **Hy-Memory-30B-A3B** on Hugging Face!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)** with Chinese, English and Spanish interfaces, one-time pairing codes for more custom Harnesses, and smoother browsing, search, startup and recovery, on **`hy-memory==2.0.8`**. See the release notes for details.
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** and **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**, with a signed macOS desktop app and verified macOS/Linux Runtimes!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Released** **Hy Memory 2.0 (`hy-memory==2.0.4`)** on [PyPI](https://pypi.org/project/hy-memory/2.0.4/) and open-sourced the **Hy-Memory** monorepo together with **[MemHub](memhub/README.md)**!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** **[Hy-Memory-30B-A3B](https://huggingface.co/tencent/Hy-Memory-30B-A3B)** on Hugging Face along with its [technical report](https://arxiv.org/abs/2610.22196) on attributed reinforcement learning for long-term memory writing!
+1
View File
@@ -33,6 +33,7 @@
## 最新动态
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **模型开源** 在 Hugging Face 发布 **Hy-Memory-30B-A3B** 的 **FP8**(**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**)与 **GGUF**(**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**)量化版本模型!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)**:支持中文、英文和西班牙语界面,可通过一次性配对码连接更多自定义 Harness,并优化了记忆浏览、搜索、启动和异常恢复;底层使用 **`hy-memory==2.0.8`**。详见发布说明。
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** 与 **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**,提供已签名的 macOS 桌面应用及经过验证的 macOS/Linux Runtime!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **正式发布** **Hy Memory 2.0 (`hy-memory==2.0.4`)**(已上线 [PyPI](https://pypi.org/project/hy-memory/2.0.4/)),并开源 **Hy-Memory** monorepo 与 **[MemHub](memhub/README.md)**!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **模型开源** 在 Hugging Face 发布 **[Hy-Memory-30B-A3B](https://huggingface.co/tencent/Hy-Memory-30B-A3B)** 长期记忆写入模型及[技术报告](https://arxiv.org/abs/2610.22196)!
+2 -2
View File
@@ -19,12 +19,12 @@ If you run into a problem, please open an Issue with the version, platform,repro
## 发行与平台合同
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.8`。MemHub 源码版本为 `1.0.5`
(build 14),GitHub 仓库为
(build 35),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
`v1.0.5` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64/x64 App 和 Runtime。
`v1.0.5` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64 App 和 Runtime。
Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命周期验收;macOS
制品由发行流水线完成 Developer ID 签名、公证和验收后手动上传。Windows 不支持。
只有同时出现在 GitHub Release 与 `release-manifest.json` 中、且通过公开校验的制品才属于
+30 -28
View File
@@ -2980,11 +2980,13 @@ def create_app(
"""
try:
client = await _sdk_client_handle(app).client()
# The map draws the page the list shows, so it reads the same
# source filter and window rather than the newest memories overall.
# The map visualizes the active source scope, independently of the
# left list's 20-row pagination window.
raw = await asyncio.to_thread(
client.list_memories,
user_id="local",
scene="all",
statuses=["active"],
include_sources=[source] if source else None,
limit=limit,
offset=offset,
@@ -3150,25 +3152,25 @@ def create_app(
raise HTTPException(status_code=422, detail="memory_task_requires_experience")
namespace = _namespace_kwargs(request.kind, request.variant)
try:
client = await app.state.sdk_write_coordinator.client()
update_kwargs: dict[str, object] = dict(namespace)
if request.task is not None:
update_kwargs["task"] = request.task
result = await asyncio.to_thread(
client.update, memory_id, request.content, **update_kwargs
)
if not isinstance(result, dict):
raise HySdkError("hy_memory_update_rejected")
if result.get("success") is not True:
if "not found" in str(result.get("error_message") or ""):
raise HTTPException(status_code=404, detail="memory_not_found")
raise HySdkError("hy_memory_update_rejected")
# The SDK names the namespace it wrote; read that one back.
if result.get("kind") in {"experience", "fact"}:
namespace = {"kind": result["kind"]}
if result.get("variant") in {"working", "coding"}:
namespace["variant"] = result["variant"]
raw = await asyncio.to_thread(client.get, memory_id, **namespace)
async with app.state.sdk_write_coordinator.use_client() as client:
update_kwargs: dict[str, object] = dict(namespace)
if request.task is not None:
update_kwargs["task"] = request.task
result = await asyncio.to_thread(
client.update, memory_id, request.content, **update_kwargs
)
if not isinstance(result, dict):
raise HySdkError("hy_memory_update_rejected")
if result.get("success") is not True:
if "not found" in str(result.get("error_message") or ""):
raise HTTPException(status_code=404, detail="memory_not_found")
raise HySdkError("hy_memory_update_rejected")
# The SDK names the namespace it wrote; read that one back.
if result.get("kind") in {"experience", "fact"}:
namespace = {"kind": result["kind"]}
if result.get("variant") in {"working", "coding"}:
namespace["variant"] = result["variant"]
raw = await asyncio.to_thread(client.get, memory_id, **namespace)
memory = None if raw is None else HySdkMemoryRecord.from_sdk(raw)
except HTTPException:
raise
@@ -3197,10 +3199,10 @@ def create_app(
) -> Response:
_check_memory_namespace(kind, variant)
try:
client = await app.state.sdk_write_coordinator.client()
result = await asyncio.to_thread(
client.delete, memory_id, **_namespace_kwargs(kind, variant)
)
async with app.state.sdk_write_coordinator.use_client() as client:
result = await asyncio.to_thread(
client.delete, memory_id, **_namespace_kwargs(kind, variant)
)
if not isinstance(result, dict):
raise HySdkError("hy_memory_delete_rejected")
error_code = result.get("error_code")
@@ -3442,9 +3444,9 @@ def create_app(
"""Retry unknown writes only behind the user's explicit confirmation."""
del request
reconciled = await _sdk_memory_runtime(app).reconcile_unknown_outputs()
from one_memory.memory_server.durable_queue import DurableWriteQueue
queue = DurableWriteQueue(app.state.memory_write_queue_path)
# The startup queue knows the authority. A fresh legacy-mode queue
# would recreate the job-evidence table the SDK cutover dropped.
queue = app.state.memory_write_queue
unknown = queue.unknown_jobs(limit=2048)
if app.state.memory_authority is MemoryAuthority.LEGACY:
for job in unknown:
@@ -67,6 +67,16 @@ _STORE_GATE = threading.Condition()
_OFFLINE_STORES: set[Path] = set()
_OPENING: dict[Path, int] = {}
# One client constructs at a time per home, its first-time store setup
# included. The constructor opens Chroma's `PersistentClient`, and two first
# opens on one directory race over that same unlocked System cache. On a
# clean install the write drain's client and a UI read client started in the
# same second; neither reached `VectorStore ready`, and the first write job
# was lost as unknown. Taken inside `acquire_client`, after the offline gate
# and never while `_STORE_GATE` is held, so it cannot deadlock with
# `hold_stores_offline`, which refuses while `_OPENING` counts a client.
_CONSTRUCTING: dict[Path, threading.Lock] = {}
class LocalOnnxEmbeddingService:
"""Public ``EmbeddingService`` implementation backed by MemHub's ONNX model."""
@@ -382,13 +392,16 @@ def _acquire_client(
embedding_service: LocalOnnxEmbeddingService | None = None
if (profile.embedding_provider_kind or profile.provider_kind) is ProviderKind.LOCAL_ONNX:
embedding_service = LocalOnnxEmbeddingService()
with _STORE_GATE:
constructing = _CONSTRUCTING.setdefault(key, threading.Lock())
try:
client = HyMemoryClient(
config=config,
mode="ultra" if graph_store_enabled() else "pro",
runtime=runtime,
embedding_service=embedding_service,
)
with constructing:
client = HyMemoryClient(
config=config,
mode="ultra" if graph_store_enabled() else "pro",
runtime=runtime,
embedding_service=embedding_service,
)
except BaseException:
_release_runtime(key)
raise
@@ -52,6 +52,7 @@ from one_memory.memory_tasks import (
from one_memory.memory_tasks import (
POLICY_STOPPED_CODES as _TASK_POLICY_STOPPED_CODES,
)
from one_memory.sdk_migration_log import error_chain_text
from one_memory.service_levels import ServiceLevel, service_level_context
logger = logging.getLogger(__name__)
@@ -241,14 +242,16 @@ PERMISSION_REQUIRED_CODES = frozenset(
)
#: Model-side codes the SDK write coordinator raises before it can lease a
#: client (no active profile, missing credential, secure storage down). They prove
#: nothing was sent, so the write parks instead of being lost. Transport codes
#: that can arrive after a request was in flight stay `unknown`.
#: client (no active profile, missing credential, secure storage down, or the
#: client failed to construct). They prove nothing was sent, so the write
#: parks instead of being lost. Transport codes that can arrive after a
#: request was in flight stay `unknown`.
MODEL_NOT_READY_CODES = frozenset(
{
"model_profile_not_active",
"model_secret_unavailable",
"secure_storage_unavailable",
"hy_memory_client_unavailable",
}
)
@@ -292,6 +295,23 @@ _VALID_JOB_STATES = frozenset(
#: A row in one of these states can never be moved back to a live one.
_TERMINAL_WRITE_STATES = frozenset({COMPLETED, FAILED, DEAD, CANCELLED})
#: The legacy task rows a write job can be linked to. The SDK cutover drops
#: them (`target_schema.LEGACY_TABLES_IN_DROP_ORDER`, inputs before tasks), so
#: the job-only paths must not assume any of them.
_LEGACY_TASK_LINK_TABLES = ("memory_tasks", "memory_task_inputs", "memory_write_job_evidence")
def _legacy_task_links_present(connection: sqlite3.Connection) -> bool:
present = {
str(row[0])
for row in connection.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name IN (?,?,?)",
_LEGACY_TASK_LINK_TABLES,
)
}
return present == set(_LEGACY_TASK_LINK_TABLES)
#: Columns a current schema declares but an older `memory_write_jobs` may lack.
#: `CREATE TABLE IF NOT EXISTS` is a no-op for an existing table, so without
#: this an upgraded install would keep an older queue that every claim/lease
@@ -1982,21 +2002,24 @@ class DurableWriteQueue:
or str(row["last_error"]) in NON_RETRYABLE_POST_WRITE_CODES
):
return 0
connection.execute(
"""
UPDATE memory_tasks
SET status='pending', runner_token=NULL, attempts=0,
last_error_code='model_content_manual_retry_requested', updated_at=?
WHERE status='unknown' AND task_id IN (
SELECT inputs.task_id
FROM memory_task_inputs AS inputs, json_each(inputs.refs_json) AS ref
JOIN memory_write_job_evidence AS linked
ON linked.evidence_id=json_extract(ref.value, '$.evidence_id')
WHERE linked.job_id=?
# After the SDK cutover the legacy task tables are gone and the
# job is the only row to reset.
if not self.sdk_authority and _legacy_task_links_present(connection):
connection.execute(
"""
UPDATE memory_tasks
SET status='pending', runner_token=NULL, attempts=0,
last_error_code='model_content_manual_retry_requested', updated_at=?
WHERE status='unknown' AND task_id IN (
SELECT inputs.task_id
FROM memory_task_inputs AS inputs, json_each(inputs.refs_json) AS ref
JOIN memory_write_job_evidence AS linked
ON linked.evidence_id=json_extract(ref.value, '$.evidence_id')
WHERE linked.job_id=?
)
""",
(now, job_id),
)
""",
(now, job_id),
)
changed = connection.execute(
"""
UPDATE memory_write_jobs
@@ -2098,9 +2121,7 @@ class DurableWriteQueue:
f"WHERE state=? AND job_id IN ({placeholders})",
(PENDING, now, UNKNOWN, *job_ids),
)
if connection.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='memory_tasks'"
).fetchone():
if _legacy_task_links_present(connection):
connection.execute(
f"UPDATE memory_tasks SET status=?, runner_token=NULL, "
f"last_error_code='model_content_reconciled_absent_retry', updated_at=? "
@@ -2158,9 +2179,7 @@ class DurableWriteQueue:
f"WHERE state=? AND attempts=1 AND job_id IN ({placeholders})",
(PENDING, now, UNKNOWN, *job_ids),
)
if connection.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='memory_tasks'"
).fetchone():
if _legacy_task_links_present(connection):
connection.execute(
f"UPDATE memory_tasks SET status=?, runner_token=NULL, "
f"last_error_code='model_content_unknown_auto_retry', updated_at=? "
@@ -2177,6 +2196,14 @@ class DurableWriteQueue:
def resolve_unknown_from_linked_output(self, job_id: str) -> bool:
"""Close an unknown durable row when local governance output proves success."""
with main_database(self.database_path).read(row_factory=sqlite3.Row) as connection:
if not (
_legacy_task_links_present(connection)
and connection.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='memory_task_outputs'"
).fetchone()
):
# No legacy governance output survives the SDK cutover.
return False
row = connection.execute(
"""
SELECT 1
@@ -2559,9 +2586,22 @@ async def execute_job(
if code == "model_profile_not_active":
logger.debug("durable_write_job_parked job_id=%s error_code=%s", job_id, code)
elif state in PARKED_STATES:
logger.info("durable_write_job_parked job_id=%s error_code=%s", job_id, code)
logger.info(
"durable_write_job_parked job_id=%s error_code=%s chain=%s",
job_id,
code,
error_chain_text(error),
)
else:
logger.error("durable_write_job_failed job_id=%s error_code=%s", job_id, code)
# Where it failed, as `file:line:function` frames of the exception
# chain, never its message: `error_code=AttributeError` alone
# named no location.
logger.error(
"durable_write_job_failed job_id=%s error_code=%s chain=%s",
job_id,
code,
error_chain_text(error),
)
return
await in_the_background(
queue.settle,
@@ -5,7 +5,8 @@ from __future__ import annotations
import asyncio
import logging
import re
from contextlib import suppress
from collections.abc import AsyncIterator
from contextlib import AsyncExitStack, asynccontextmanager, suppress
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
@@ -164,44 +165,45 @@ class SdkMemoryRuntime:
if str(error) == "model_profile_not_active":
return HyGraphSnapshot(consulted=False)
raise
try:
client = await self.coordinator.client()
except Exception as error:
raise HySdkError("hy_memory_graph_read_failed") from error
return await asyncio.to_thread(
read_graph_snapshot,
client,
"local",
"*",
limit,
)
async with AsyncExitStack() as stack:
try:
client = await stack.enter_async_context(self.coordinator.use_client())
except Exception as error:
raise HySdkError("hy_memory_graph_read_failed") from error
return await asyncio.to_thread(
read_graph_snapshot,
client,
"local",
"*",
limit,
)
async def digest(self) -> dict[str, object]:
client = await self.coordinator.client()
try:
raw = await asyncio.to_thread(
client.list_memories,
user_id="local",
limit=1_000,
offset=0,
)
agent_ids = _agent_ids(raw)
digests = [
{
"agent_id": agent_id,
"schemas_recorded": 0,
**dict(
await asyncio.to_thread(
client.evolve,
user_id="local",
agent_id=agent_id,
)
),
}
for agent_id in agent_ids
]
except Exception as error:
raise HySdkError("hy_memory_digest_failed") from error
async with self.coordinator.use_client() as client:
try:
raw = await asyncio.to_thread(
client.list_memories,
user_id="local",
limit=1_000,
offset=0,
)
agent_ids = _agent_ids(raw)
digests = [
{
"agent_id": agent_id,
"schemas_recorded": 0,
**dict(
await asyncio.to_thread(
client.evolve,
user_id="local",
agent_id=agent_id,
)
),
}
for agent_id in agent_ids
]
except Exception as error:
raise HySdkError("hy_memory_digest_failed") from error
return {
"success": all(result.get("success") is not False for result in digests),
"scope_count": len(digests),
@@ -240,36 +242,39 @@ class SdkMemoryRuntime:
decision=decision,
)
async def _mutation_client(self) -> Any:
@asynccontextmanager
async def _mutation_client(self) -> AsyncIterator[Any]:
"""The write client, with no-model and keychain refusals as SDK codes.
Callers record these against the edit or deletion instead of failing
the request that already committed it.
"""
try:
return await self.coordinator.client()
except RuntimeError as error:
code = str(error)
if not _STABLE_CODE.fullmatch(code):
code = "hy_memory_client_unavailable"
raise HySdkError(code) from error
async with AsyncExitStack() as stack:
try:
client = await stack.enter_async_context(self.coordinator.use_client())
except RuntimeError as error:
code = str(error)
if not _STABLE_CODE.fullmatch(code):
code = "hy_memory_client_unavailable"
raise HySdkError(code) from error
yield client
async def update_hy_memory(self, memory_id: str, content: str) -> dict[str, object]:
client = await self._mutation_client()
try:
result = await asyncio.to_thread(client.update, memory_id, content)
except Exception as error:
raise HySdkError("hy_memory_update_failed") from error
async with self._mutation_client() as client:
try:
result = await asyncio.to_thread(client.update, memory_id, content)
except Exception as error:
raise HySdkError("hy_memory_update_failed") from error
if not isinstance(result, dict) or result.get("success") is not True:
raise HySdkError("hy_memory_update_rejected")
return result
async def finish_deletion(self, memory_id: str) -> None:
client = await self._mutation_client()
try:
result = await asyncio.to_thread(client.delete, memory_id)
except Exception as error:
raise HySdkError("hy_memory_delete_failed") from error
async with self._mutation_client() as client:
try:
result = await asyncio.to_thread(client.delete, memory_id)
except Exception as error:
raise HySdkError("hy_memory_delete_failed") from error
if not isinstance(result, dict):
raise HySdkError("hy_memory_delete_rejected")
error_code = result.get("error_code")
@@ -35,6 +35,8 @@ from one_memory.memory_server.durable_queue import (
COMPLETED,
FAILED,
REAPED_PAYLOAD_MARKER,
RUNNING,
UNKNOWN,
DurableWriteQueue,
)
from one_memory.memory_server.unified_entry import UnifiedWriteIntake
@@ -317,11 +319,45 @@ def move_pending_dispatches(database_path: Path) -> int:
return len(conversations)
#: Why a pre-cutover write job that may have reached HY was closed.
ABANDONED_AT_CUTOVER = "abandoned_at_cutover"
def settle_legacy_unknown_write_jobs(database_path: Path) -> int:
"""Close write jobs the legacy pipeline left possibly-sent. Safe to repeat.
They ran under the legacy pipeline, so the SDK's `get_write_outcome` knows
none of them and the unknown reconcile can never settle them: a migrated
1.0.4 home showed 1000+ unknown forever. They are not retried, since they
may already have written and the SDK has no idempotency key. `cancelled`,
not `failed`: the upgrade ended them, nothing broke, and a `failed` count
would put the home in the failed state for good. `hy_invoked` is kept, so
no readmission path can pick one up again.
"""
with main_database(database_path).read(row_factory=sqlite3.Row) as database:
if "memory_write_jobs" not in _tables(database):
return 0
now = datetime.now(UTC).isoformat()
with main_database(database_path).write(autocommit=True) as database:
# A `running` row already marked invoked would be reclaimed to
# `unknown` on the next open, so it is the same case.
return int(
database.execute(
"UPDATE memory_write_jobs SET state=?,lease_owner=NULL,"
"lease_expires_at=NULL,last_error=?,updated_at=? "
"WHERE state=? OR (state=? AND hy_invoked=1)",
(CANCELLED, ABANDONED_AT_CUTOVER, now, UNKNOWN, RUNNING),
).rowcount
)
def abandoned_legacy_work(database_path: Path) -> dict[str, int]:
"""Count legacy work that ends with the cutover instead of blocking it.
A task that may have reached the SDK is not replayed: the SDK already holds
whatever it committed. Pending user deletions are not carried over.
Settled write jobs are counted by their code, so a repeated preparation
reports the same number.
"""
abandoned: dict[str, int] = {}
with main_database(database_path).read(row_factory=sqlite3.Row) as database:
@@ -332,6 +368,11 @@ def abandoned_legacy_work(database_path: Path) -> dict[str, int]:
"memory_tasks",
"status='unknown' OR (status='running' AND hy_invocation_count > 0)",
),
(
"unknown_write_jobs",
"memory_write_jobs",
f"state='{CANCELLED}' AND last_error='{ABANDONED_AT_CUTOVER}'",
),
("deletions", "memory_deletions", "status IN ('pending','running')"),
)
for name, table, where in checks:
@@ -354,6 +395,10 @@ def prepare_legacy_work_for_cutover(
running, so a failure names it.
"""
trace = {} if trace is None else trace
# First: a possibly-sent job never runs again, so its input is not
# frozen, and input released since must not block the cutover.
trace["sub_step"] = "unknown_write_jobs"
settle_legacy_unknown_write_jobs(database_path)
trace["sub_step"] = "write_queue"
frozen, moved = prepare_legacy_write_queue_for_cutover(database_path)
trace["sub_step"] = "pending_dispatches"
@@ -3,7 +3,8 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Mapping, Sequence
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import datetime
from typing import Any
@@ -56,7 +57,12 @@ class SdkWriteProcessor:
scope = _write_scope(records)
metadata = _write_metadata(records, scope)
client = await self._client_handle.client()
try:
client = await self._client_handle.client()
except Exception as error:
# Before `before_hy_invocation`: nothing was sent, so the job
# waits and retries instead of being left `unknown`.
raise RuntimeError("hy_memory_client_unavailable") from error
if extracted:
contents = [sanitize_model_input(record.verbatim) for record in records]
@@ -125,7 +131,14 @@ class SdkWriteProcessor:
class SdkWriteCoordinator:
"""Resolve the active model profile, then execute one public SDK write."""
"""Resolve the active model profile, then execute one public SDK write.
A model profile change retires the previous profile's handle, but only
closes it once nothing uses it. Closing it at once tore the client out
from under a write still running on it: the SDK cleared its stores and
the SharedRuntime closed mid-`add`, the job failed with AttributeError
after HY was invoked, and it was left `unknown`.
"""
def __init__(
self,
@@ -142,6 +155,9 @@ class SdkWriteCoordinator:
self.transport_factory = transport_factory
self.sedimentation_enabled = sedimentation_enabled
self._handles: dict[tuple[str, int], SdkClientHandle] = {}
#: Uses in flight per handle; a retired handle closes at zero.
self._uses: dict[SdkClientHandle, int] = {}
self._retired: set[SdkClientHandle] = set()
self._lock = asyncio.Lock()
async def process_sdk_job(
@@ -153,28 +169,30 @@ class SdkWriteCoordinator:
) -> SdkWriteReceipt:
if not self.sedimentation_enabled():
raise RuntimeError("model_sedimentation_disabled")
handle = await self._active_handle()
return await SdkWriteProcessor(handle).process_sdk_job(
records,
request_id=request_id,
before_hy_invocation=before_hy_invocation,
)
async with self._active_handle() as handle:
return await SdkWriteProcessor(handle).process_sdk_job(
records,
request_id=request_id,
before_hy_invocation=before_hy_invocation,
)
async def inquire_write_outcome(
self, request_id: str
) -> SdkWriteReceipt | None:
return await SdkWriteProcessor(
await self._active_handle()
).inquire_write_outcome(request_id)
async with self._active_handle() as handle:
return await SdkWriteProcessor(handle).inquire_write_outcome(request_id)
async def close(self) -> None:
async with self._lock:
handles, self._handles = tuple(self._handles.values()), {}
handles = (*self._handles.values(), *self._retired)
self._handles, self._retired, self._uses = {}, set(), {}
await asyncio.gather(*(handle.close() for handle in handles))
async def client(self) -> Any:
"""Return the public SDK client selected by the active write profile."""
return await (await self._active_handle()).client()
@asynccontextmanager
async def use_client(self) -> AsyncIterator[Any]:
"""The public SDK client of the active write profile, kept open while used."""
async with self._active_handle() as handle:
yield await handle.client()
async def active_profile_credentials(
self,
@@ -183,7 +201,9 @@ class SdkWriteCoordinator:
_identity, profile, secret = await asyncio.to_thread(self._read_active_credentials)
return profile, secret
async def _active_handle(self) -> SdkClientHandle:
@asynccontextmanager
async def _active_handle(self) -> AsyncIterator[SdkClientHandle]:
"""Use the active profile's handle; retire the others, closing the unused."""
identity, profile, secret = await asyncio.to_thread(
self._read_active_credentials
)
@@ -197,14 +217,25 @@ class SdkWriteCoordinator:
http_transport_factory=self.transport_factory(profile),
)
self._handles[identity] = handle
stale = [
value
for key, value in self._handles.items()
if key != identity
]
self._retired.update(value for key, value in self._handles.items() if key != identity)
self._handles = {identity: handle}
await asyncio.gather(*(value.close() for value in stale))
return handle
self._uses[handle] = self._uses.get(handle, 0) + 1
unused = {value for value in self._retired if not self._uses.get(value)}
self._retired -= unused
await asyncio.gather(*(value.close() for value in unused))
try:
yield handle
finally:
# Bookkeeping without an await, so a cancelled caller still
# releases its use.
remaining = self._uses.get(handle, 0) - 1
if remaining > 0:
self._uses[handle] = remaining
else:
self._uses.pop(handle, None)
if remaining <= 0 and handle in self._retired:
self._retired.discard(handle)
await handle.close()
def _read_active_credentials(
self,
+2 -1
View File
@@ -246,6 +246,7 @@
| `test_schema_gate.py` | `contract` | 覆盖 fresh database records the build schema、future schema refuses to open、legacy database without a ledger is left untouched 场景。 |
| `test_schema_migrations.py` | `contract` | 覆盖 evidence store adds source ordinal to previous schema、import store migrates previous job and error schema、old active job index lets a sync sit beside paused history 等 4 个场景。 |
| `test_schema_ownership_gate.py` | `contract` | A1-03/A1-04: schema ownership, and the hard part of telling DDL from reads. |
| `test_sdk_client_construction.py` | `contract` | SDK clients of one home are constructed one at a time. |
| `test_sdk_data_plane_migration.py` | `contract` | SDK-authority migration ledger, aliases and atomic cutover marker. |
| `test_sdk_memory_qa.py` | `contract` | 覆盖 sdk ask propagates sdk search failure without legacy fallback、sdk ask revalidates memory before returning extract、sdk ask uses native sdk memory without governance fields 场景。 |
| `test_sdk_migration_bundle.py` | `contract` | SDK-authority cutover requires a verified management + SDK data-plane bundle. |
@@ -308,4 +309,4 @@
| `test_workspace_recall.py` | `contract` | Workspace memories were unreachable from every host; these pin the way in. |
| `test_write_queue_lease_renewal.py` | `contract` | Rule 8: a lease must outlive the work it covers, and be renewed while it runs. |
共 303 个测试文件。
共 304 个测试文件。
@@ -14,8 +14,8 @@ from pathlib import Path
import pytest
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
EXPECTED_TEST_FILE_COUNT = 303
EXPECTED_TEST_FILES_SHA256 = "7993203411697fc0bf42b92fa316f222a66e6826c5b4a0132dbb0a1ca6da4e32"
EXPECTED_TEST_FILE_COUNT = 304
EXPECTED_TEST_FILES_SHA256 = "b28f022b9688c96ca563c59de63e95d344d2f01ba3b6020b422bf4dfb27520d6"
# Pure transformations, validation models, parity checks and state machines.
# This list is intentionally small: uncertain files default to contract.
@@ -6,6 +6,7 @@ and delete pass the memory's kind/variant through to the SDK.
"""
import asyncio
from contextlib import asynccontextmanager
import pytest
from httpx import ASGITransport, AsyncClient
@@ -310,12 +311,13 @@ def run_with_sdk(tmp_path, monkeypatch, scenario):
async def close(self):
return None
@asynccontextmanager
async def write_client():
return sdk
yield sdk
async with app.router.lifespan_context(app):
app.state.sdk_client_handle = Handle()
monkeypatch.setattr(app.state.sdk_write_coordinator, "client", write_client)
monkeypatch.setattr(app.state.sdk_write_coordinator, "use_client", write_client)
async with client_for(app) as client:
await scenario(app, sdk, client)
@@ -1187,6 +1187,167 @@ def test_the_drain_tick_writes_a_quiet_live_conversation(tmp_path, monkeypatch)
assert flushed
class _SwitchableProfiles:
"""Active profile identity that a test can change mid-write."""
def __init__(self) -> None:
self.identity = ("profile-a", 1)
def active_identity(self):
return self.identity
def snapshot(self, profile_id, revision):
return type(
"Snapshot", (), {"profile_id": profile_id, "revision": revision, "has_api_key": False}
)()
def resolve_snapshot_secret(self, *_args):
return None
class _TrackedHandle:
def __init__(self, profile, **_kwargs) -> None:
self.profile_id = profile.profile_id
self.closed = False
async def client(self):
return self
async def close(self):
self.closed = True
def _coordinator(monkeypatch, opened: list[_TrackedHandle]):
from one_memory import sdk_write_processor
def handle(profile, **kwargs):
value = _TrackedHandle(profile, **kwargs)
opened.append(value)
return value
monkeypatch.setattr(sdk_write_processor, "SdkClientHandle", handle)
profiles = _SwitchableProfiles()
coordinator = sdk_write_processor.SdkWriteCoordinator(
profiles=profiles, # type: ignore[arg-type]
secrets=None, # type: ignore[arg-type]
data_dir=None,
transport_factory=lambda _profile: None,
sedimentation_enabled=lambda: True,
)
return coordinator, profiles
def test_a_profile_change_does_not_close_the_client_of_a_write_in_flight(monkeypatch) -> None:
"""Build 35 lost a clean install's first write as unknown (AttributeError).
Saving a model profile changed the active identity while the write ran on
the old profile's client. The next coordinator call closed that client at
once: the SDK cleared its stores and the SharedRuntime closed mid-`add`.
"""
from one_memory import sdk_write_processor
opened: list[_TrackedHandle] = []
coordinator, profiles = _coordinator(monkeypatch, opened)
seen: list[tuple[str, bool]] = []
writing = asyncio.Event()
async def process(self, records, *, request_id, before_hy_invocation):
client = await self._client_handle.client()
await before_hy_invocation()
seen.append((client.profile_id, client.closed))
if client.profile_id == "profile-a":
await writing.wait()
# Still open while its own write runs.
seen.append((client.profile_id, client.closed))
return sdk_write_processor.SdkWriteReceipt()
monkeypatch.setattr(sdk_write_processor.SdkWriteProcessor, "process_sdk_job", process)
async def noop():
return None
async def scenario():
first = asyncio.create_task(
coordinator.process_sdk_job((), request_id="a", before_hy_invocation=noop)
)
while not seen:
await asyncio.sleep(0)
profiles.identity = ("profile-b", 2)
# A second job and an inquiry under the new profile.
await coordinator.process_sdk_job((), request_id="b", before_hy_invocation=noop)
assert [handle.closed for handle in opened] == [False, False]
writing.set()
await first
old, new = opened
# Retired after its last use, and new work used the new profile.
assert old.closed is True and new.closed is False
await coordinator.close()
assert new.closed is True
asyncio.run(scenario())
assert seen == [
("profile-a", False),
("profile-b", False),
("profile-b", False),
("profile-a", False),
]
def test_an_unused_stale_handle_still_closes_at_once(monkeypatch) -> None:
opened: list[_TrackedHandle] = []
coordinator, profiles = _coordinator(monkeypatch, opened)
async def scenario():
async with coordinator.use_client() as client:
assert client.profile_id == "profile-a"
assert opened[0].closed is False
profiles.identity = ("profile-b", 2)
async with coordinator.use_client() as client:
assert client.profile_id == "profile-b"
assert opened[0].closed is True
assert opened[1].closed is False
await coordinator.close()
assert opened[1].closed is True
asyncio.run(scenario())
def test_a_client_that_fails_to_construct_parks_the_job_instead_of_unknown(tmp_path) -> None:
"""Nothing was sent when the client could not be built, so it retries on its own."""
from one_memory.sdk_write_processor import SdkWriteProcessor
class _BrokenHandle:
async def client(self):
raise AttributeError("'RustBindingsAPI' object has no attribute 'bindings'")
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
record = queue.submit(_request())
_claim_through_drain(queue, record.job_id)
class _Supervisor:
async def process_sdk_job(self, _records, **kwargs):
return await SdkWriteProcessor(_BrokenHandle()).process_sdk_job( # type: ignore[arg-type]
(_sdk_evidence("e1"),), **kwargs
)
asyncio.run(
execute_job(
record.job_id,
queue=queue,
supervisor=_Supervisor(),
evidence=_EvidenceStore({"e1"}),
)
)
[row] = _rows(path)
assert (row["state"], row["hy_invoked"]) == (WAITING_FOR_MODEL, 0)
assert row["last_error"] == "hy_memory_client_unavailable"
later = datetime.now(UTC) + timedelta(minutes=5)
assert queue.readmit_model_waiters(now=later) == 1
assert _state(path, record.job_id) == PENDING
def test_sdk_unknown_outcome_inquiry_uses_only_public_receipt() -> None:
from one_memory.sdk_write_processor import SdkWriteProcessor, SdkWriteReceipt
@@ -1699,6 +1860,46 @@ def test_a_raw_provider_message_is_never_stored_as_the_error(tmp_path) -> None:
assert stored == "RuntimeError"
def test_a_failed_job_logs_where_it_failed_but_never_the_message(tmp_path, caplog) -> None:
"""`error_code=AttributeError` alone named no location; frames, never the message."""
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
record = queue.submit(_request())
_claim_through_drain(queue, record.job_id)
def closed_store(client):
return client.private_store_name.write
class _Failing:
async def process_sdk_job(self, _records, **kwargs):
await kwargs["before_hy_invocation"]()
try:
closed_store(type("Client", (), {"private_store_name": None})())
except AttributeError as error:
raise RuntimeError("quoting 'my secret diary'") from error
with caplog.at_level(logging.ERROR, logger="one_memory.memory_server.durable_queue"):
asyncio.run(
execute_job(
record.job_id,
queue=queue,
supervisor=_Failing(),
evidence=_EvidenceStore({"e1"}),
)
)
[line] = [
message.getMessage()
for message in caplog.records
if "durable_write_job_failed" in message.getMessage()
]
assert f"job_id={record.job_id} error_code=RuntimeError chain=RuntimeError@" in line
assert ";AttributeError@" in line
assert "test_memory_server_durable_queue.py:" in line and ":closed_store" in line
assert "secret diary" not in line
assert "private_store_name" not in line
def test_a_machine_error_code_is_preserved(tmp_path) -> None:
"""Recognised codes survive; only free-form text is dropped."""
from one_memory.memory_server.durable_queue import safe_error_code
@@ -0,0 +1,61 @@
"""SDK clients of one home are constructed one at a time.
Build 35, clean install: right after the model was activated, the write
drain's client and a UI read client started in the same second. Each opened
Chroma on the same empty vector directory, Chroma's shared System cache has
no lock around that first open, and neither client reached `VectorStore
ready`. The first write job was lost as unknown.
"""
import threading
import time
from one_memory import hy_sdk_runtime
from one_memory.hy_sdk_runtime import acquire_client, builtin_retrieval_snapshot
def test_two_clients_on_a_fresh_home_are_constructed_one_at_a_time(tmp_path, monkeypatch):
real = hy_sdk_runtime.HyMemoryClient
state = {"inside": 0, "most": 0}
count = threading.Lock()
def recording_client(**options):
with count:
state["inside"] += 1
state["most"] = max(state["most"], state["inside"])
try:
# Widen the window two unserialized constructors would share.
time.sleep(0.2)
return real(**options)
finally:
with count:
state["inside"] -= 1
monkeypatch.setattr(hy_sdk_runtime, "HyMemoryClient", recording_client)
start = threading.Barrier(2)
leases: list[hy_sdk_runtime.ClientLease] = []
failures: list[BaseException] = []
def open_client():
start.wait()
try:
leases.append(
acquire_client(builtin_retrieval_snapshot(), api_key=None, data_dir=tmp_path)
)
except BaseException as error: # noqa: BLE001 - reported below
failures.append(error)
threads = [threading.Thread(target=open_client) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(120)
try:
assert failures == []
assert len(leases) == 2
assert state["most"] == 1
for lease in leases:
assert lease.client.memory_stats(user_id="local") is not None
finally:
for lease in leases:
lease.close()
@@ -37,6 +37,7 @@ from one_memory.sdk_migration_runner import (
abandoned_legacy_work,
commit_sdk_cutover,
move_pending_dispatches,
prepare_legacy_work_for_cutover,
prepare_legacy_write_queue_for_cutover,
recover_abandoned_staging,
resume_legacy_cleanup,
@@ -896,6 +897,116 @@ def test_legacy_work_that_may_have_reached_the_sdk_does_not_block_cutover(tmp_pa
assert store.authority() is MemoryAuthority.SDK
def test_pre_upgrade_unknown_writes_are_settled_at_cutover_not_retried(store):
"""A migrated 1.0.4 home kept 1000+ jobs unknown forever.
The legacy pipeline invoked them, so the SDK's write-outcome inquiry knows
none of them and the automatic reconcile could never settle one.
"""
from one_memory.memory_server.durable_queue import (
CANCELLED,
PENDING,
RUNNING,
UNKNOWN,
)
queue = DurableWriteQueue(store.database_path)
def job(key: str, state: str, *, invoked: bool) -> str:
identifier = queue.submit(
WriteRequest(
internal_session_id="session-1",
source_id="cursor",
batch_identity=BatchIdentity(
internal_session_id="session-1", first_round=1, last_round=1
),
evidence_ids=["gone"],
idempotency_key=key,
)
).job_id
with main_database(store.database_path).write() as database, database:
database.execute(
"UPDATE memory_write_jobs SET state=?,hy_invoked=?,"
"last_error='model_content_outcome_unknown' WHERE job_id=?",
(state, int(invoked), identifier),
)
return identifier
# Their Evidence is gone too, which must not block the cutover.
unknown = [job(f"unknown-{index}", UNKNOWN, invoked=True) for index in range(3)]
sent_running = job("sent-running", RUNNING, invoked=True)
finished = job("finished", "completed", invoked=True)
first = prepare_legacy_work_for_cutover(store.database_path)
assert first.abandoned == {"unknown_write_jobs": 4}
# Resumed after an interruption: nothing changes, the count is the same.
again = prepare_legacy_work_for_cutover(store.database_path)
assert again.as_document() == first.as_document()
with main_database(store.database_path).read() as database:
rows = dict(
(str(row[0]), (str(row[1]), str(row[2]), int(row[3])))
for row in database.execute(
"SELECT job_id,state,last_error,hy_invoked FROM memory_write_jobs"
)
)
for identifier in (*unknown, sent_running):
assert rows[identifier] == (CANCELLED, "abandoned_at_cutover", 1)
assert rows[finished][0] == "completed"
surviving = DurableWriteQueue(store.database_path, sdk_authority=True)
assert surviving.unknown_jobs() == ()
# Never requeued: they may already have written.
assert surviving.retry_unknown_confirmed(unknown[0]) == 0
assert surviving.readmit_parked() == 0
assert PENDING not in {state for state, _, _ in rows.values()}
def test_retrying_unknown_writes_works_after_the_legacy_tables_are_dropped(store):
"""The retry button raised `no such table: memory_tasks` after an upgrade."""
from one_memory.memory_server.durable_queue import PENDING, UNKNOWN
from one_memory.target_schema import drop_legacy_tables
queue = DurableWriteQueue(store.database_path)
identifier = queue.submit(
WriteRequest(
internal_session_id="session-1",
source_id="cursor",
batch_identity=BatchIdentity(
internal_session_id="session-1", first_round=1, last_round=1
),
evidence_ids=["e1"],
idempotency_key="after-upgrade",
)
).job_id
drop_legacy_tables(store.database_path)
assert legacy_tables_present(store.database_path) == ()
old = datetime(2000, 1, 1, tzinfo=UTC).isoformat()
def make_unknown(code: str, attempts: int) -> None:
with main_database(store.database_path).write() as database, database:
database.execute(
"UPDATE memory_write_jobs SET state=?,hy_invoked=1,last_error=?,"
"attempts=?,updated_at=? WHERE job_id=?",
(UNKNOWN, code, attempts, old, identifier),
)
for sdk_authority in (True, False):
surviving = DurableWriteQueue(store.database_path, sdk_authority=sdk_authority)
make_unknown("model_content_outcome_unknown", 1)
assert surviving.resolve_unknown_from_linked_output(identifier) is False
assert surviving.retry_unknown_confirmed(identifier) == 1
assert surviving.get("after-upgrade").state == PENDING
make_unknown("hy_memory_timeout", 1)
since = datetime.now(UTC)
assert surviving.retry_timed_out_unknown_after_reconcile(older_than=since) == 1
make_unknown("model_content_outcome_unknown", 1)
assert surviving.retry_first_unknown_after_reconcile(older_than=since) == 1
if sdk_authority:
# The SDK-authority queue never brings a dropped table back.
assert legacy_tables_present(store.database_path) == ()
def _protective(home: Path) -> Path:
return home / "hy-memory" / ".governed-v2.pre-migration-0123456789abcdef"
@@ -1264,6 +1375,8 @@ def test_the_sdk_memory_map_draws_sdk_memories_without_legacy_tables(tmp_path) -
graph = asyncio.run(scenario())
assert listed[0]["include_sources"] == ["cursor"]
assert listed[0]["scene"] == "all"
assert listed[0]["statuses"] == ["active"]
assert (listed[0]["offset"], listed[0]["limit"]) == (100, 100)
assert (graph["total_memories"], graph["projected_memories"]) == (7, 4)
assert {node["node_id"] for node in graph["nodes"]} == {
@@ -1288,6 +1401,71 @@ def test_the_sdk_memory_map_draws_sdk_memories_without_legacy_tables(tmp_path) -
assert graph["hy_graph_available"] is True
@pytest.mark.integration
@pytest.mark.fresh_sdk
def test_sdk_memory_map_still_draws_memories_from_a_revoked_source(tmp_path) -> None:
# Revoking a source stops new reads from it; memories already distilled
# from it stay visible, so the map agrees with the list.
async def scenario():
app = create_app(_app_settings(tmp_path), secret_store=InMemorySecretStore())
class Client:
def list_memories(self, **_kwargs):
return {
"vdb": {
"memories": [
{
"memory_id": "kept",
"content": "distilled before revocation",
"layer": "l2_fact",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"session_id": "s1",
"source": "cursor",
}
],
"total": 1,
}
}
class Handle:
async def client(self):
return Client()
async def close(self):
return None
async with (
app.router.lifespan_context(app),
AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": "Bearer authority-test"},
) as client,
):
app.state.sdk_client_handle = Handle()
database = app.state.settings.data_dir / "one-memory.sqlite3"
with sqlite3.connect(database) as db:
db.execute(
"INSERT INTO source_grants "
"(user_id,source_id,root_path,requested_path,root_identity,"
"candidate_kind,granted_at,revoked_at,revision) VALUES "
"('local','cursor','/fixture','/fixture','fixture','default',"
"'2026-10-01T00:00:00Z','2026-10-02T00:00:00Z',1)"
)
response = await client.get(
"/api/v2/memories/graph",
params={"source": "cursor", "limit": 100},
)
assert response.status_code == 200
return response.json()
graph = asyncio.run(scenario())
assert (graph["total_memories"], graph["projected_memories"]) == (1, 1)
assert "memory:kept" in {node["node_id"] for node in graph["nodes"]}
def test_a_completed_sdk_write_does_not_touch_retired_evidence(store) -> None:
"""Build 21 logged RetiredAuthorityAccess after every successful write."""
from types import SimpleNamespace
+121 -9
View File
@@ -172,7 +172,7 @@ it.each([
expect(sedimentation).toHaveBeenCalledOnce();
},
);
it("localizes failure and empty states without leaking errors", async () => {
it("localizes startup recovery and empty states without leaking errors", async () => {
summary.mockRejectedValue(new Error("synthetic_private_error"));
Object.assign(window.oneMemory ?? {}, {
getLanguagePreference: vi
@@ -191,12 +191,15 @@ it("localizes failure and empty states without leaking errors", async () => {
</LanguageProvider>,
);
expect(
await screen.findByText("No se puede leer el estado de las memorias"),
await screen.findByText(
"El servicio se está iniciando; se actualizará automáticamente",
),
).toBeTruthy();
expect(screen.getByText("0")).toBeTruthy();
expect(screen.getByText("Aún no hay aplicaciones para mostrar")).toBeTruthy();
expect(
screen.getByRole("button", { name: "Reintentar lectura" }),
).toBeTruthy();
screen.queryByRole("button", { name: "Reintentar lectura" }),
).toBeNull();
expect(document.body.textContent).not.toContain("synthetic_private_error");
});
it("shows latest recorded call separately without exposing recent-update detail entries", async () => {
@@ -765,7 +768,7 @@ it("keeps authoritative home and processing data when only the source snapshot b
expect(screen.getAllByText("整理中")).toHaveLength(2);
expect(screen.queryByText("状态待核对")).toBeNull();
});
it("does not turn a failed home summary into zero or expose a private error", async () => {
it("keeps a neutral zero while the first home summary read recovers", async () => {
summary.mockRejectedValue(new Error("private offline detail"));
render(
<ApplicationsHome
@@ -775,10 +778,117 @@ it("does not turn a failed home summary into zero or expose a private error", as
onSettings={() => {}}
/>,
);
await waitFor(() => expect(screen.getByText("暂无法读取")).toBeTruthy());
expect(screen.queryByText("0")).toBeNull();
await waitFor(() =>
expect(screen.getByText("后台正在启动,稍后自动更新")).toBeTruthy(),
);
expect(screen.getByText("0")).toBeTruthy();
expect(screen.getByText("正在准备记忆概览")).toBeTruthy();
expect(screen.queryByText(/暂时?无法读取|读取失败/)).toBeNull();
expect(document.body.textContent).not.toContain("private offline detail");
});
it("offers a calm retry after repeated home summary failures", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
summary.mockRejectedValue(new Error("private offline detail"));
render(
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={() => {}}
onSettings={() => {}}
/>,
);
await waitFor(() =>
expect(screen.getByText("后台正在启动,稍后自动更新")).toBeTruthy(),
);
await act(async () => vi.advanceTimersByTimeAsync(10_000));
expect(await screen.findByText("记忆概览还没准备好")).toBeTruthy();
expect(
screen.getByText("后台恢复后会自动更新,也可以重新读取。"),
).toBeTruthy();
expect(screen.getByRole("button", { name: "重新读取" })).toBeTruthy();
} finally {
vi.useRealTimers();
}
});
it("counts repeated stale summaries and recovers on the next confirmed read", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const stale = {
memory_count: 0,
latest: null,
observed_at: "2026-09-19T00:00:00Z",
stale: true,
};
summary
.mockResolvedValueOnce(stale)
.mockResolvedValueOnce(stale)
.mockResolvedValue({
memory_count: 4,
latest: null,
observed_at: "2026-09-19T00:01:00Z",
});
render(
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={() => {}}
onSettings={() => {}}
/>,
);
await waitFor(() =>
expect(screen.getByText("后台正在启动,稍后自动更新")).toBeTruthy(),
);
await act(async () => vi.advanceTimersByTimeAsync(10_000));
expect(await screen.findByText("记忆概览还没准备好")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "重新读取" }));
expect(await screen.findByText("4")).toBeTruthy();
expect(screen.queryByText("记忆概览还没准备好")).toBeNull();
} finally {
vi.useRealTimers();
}
});
it("labels a retained count as previous data after repeated stale reads", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const stale = {
memory_count: 0,
latest: null,
observed_at: "2026-09-19T00:00:00Z",
stale: true,
};
summary
.mockResolvedValueOnce({
memory_count: 601,
latest: null,
observed_at: "2026-09-19T00:00:00Z",
})
.mockResolvedValueOnce(stale)
.mockResolvedValueOnce(stale)
.mockResolvedValue({
memory_count: 4,
latest: null,
observed_at: "2026-09-19T00:01:00Z",
});
render(
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={() => {}}
onSettings={() => {}}
/>,
);
expect(await screen.findByText("601")).toBeTruthy();
await act(async () => vi.advanceTimersByTimeAsync(20_000));
expect(screen.getByText("601")).toBeTruthy();
expect(screen.getByText("正在显示上次数据,稍后自动更新")).toBeTruthy();
await act(async () => vi.advanceTimersByTimeAsync(10_000));
expect(await screen.findByText("4")).toBeTruthy();
expect(screen.queryByText("正在显示上次数据,稍后自动更新")).toBeNull();
} finally {
vi.useRealTimers();
}
});
it("never calls a recall event a success when only stored memories are available", async () => {
render(
<ApplicationsHome
@@ -1200,7 +1310,7 @@ it("keeps the conversation-sync checking copy before the first home read", () =>
expect(screen.queryByText(/个应用已开启/)).toBeNull();
});
it("does not claim there are no memories while the first summary read is pending", async () => {
it("shows a provisional zero while the first summary read is pending", async () => {
summary.mockImplementation(() => new Promise(() => {}));
history.mockResolvedValue({
available: true,
@@ -1216,7 +1326,9 @@ it("does not claim there are no memories while the first summary read is pending
onSettings={() => {}}
/>,
);
await screen.findByText("正在读取动态");
await screen.findByText("正在准备记忆概览");
expect(screen.getByText("0")).toBeTruthy();
expect(screen.getByText("正在核对本机记忆…")).toBeTruthy();
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
});
+41 -45
View File
@@ -195,7 +195,7 @@ export function ApplicationsHome({
null,
);
const [syncHealth, setSyncHealth] = useState<LiveSyncHealth[]>([]);
const [failed, setFailed] = useState(false);
const [summaryFailures, setSummaryFailures] = useState(0);
const [recentCall, setRecentCall] = useState<CallEvent | null>(null);
const [callEvents, setCallEvents] = useState<CallEvent[]>([]);
const [callState, setCallState] = useState<
@@ -554,13 +554,17 @@ export function ApplicationsHome({
busy = false;
if (stopped) return;
if (home.status === "fulfilled") {
setFailed(false);
// stale=true 表示这轮读取超时,数字不代表现状:保留上一次的概览,
// 等 10 秒后的下一轮轮询补上,绝不清零也不报错。
if (!("stale" in home.value) || !home.value.stale)
// 等 10 秒后的下一轮轮询补上。重复 stale 仍要进入可恢复状态,
// 不能永远停在“正在核对”。
if (!("stale" in home.value) || !home.value.stale) {
setSummaryFailures(0);
setSummary(home.value);
} else {
setSummaryFailures((count) => count + 1);
}
} else {
setFailed(true);
setSummaryFailures((count) => count + 1);
}
if (state.status === "fulfilled") setProcessing(state.value);
if (sync.status === "fulfilled") setSyncHealth(sync.value);
@@ -578,7 +582,8 @@ export function ApplicationsHome({
}, [active, requestKey]);
const visible = summary;
const latest = visible?.latest;
const summaryUnavailable = failed;
const summaryUnavailable = visible === null && summaryFailures >= 2;
const summaryStale = visible !== null && summaryFailures >= 2;
const hasCall =
recentCall !== null &&
(callState === "ready" || callState === "incomplete");
@@ -642,7 +647,7 @@ export function ApplicationsHome({
? t("apps.running")
: sedimentation?.enabled === false
? t("apps.paused")
: t("apps.unchecked");
: t("apps.checkingStatus");
const stateDetail =
sedimentation?.enabled === false
? t("apps.stoppedNew")
@@ -821,42 +826,27 @@ export function ApplicationsHome({
<h2>{t("apps.overview")}</h2>
</header>
<div className="r6-home-metrics">
<div>
<div role="status" aria-live="polite" aria-atomic="true">
<p>{t("apps.memories")}</p>
{/* The figure slot used to hold the word 读取中 while the first
read was in flight, which is how "大数字不可见" (D51) shows up
on a slow link: the biggest type on the page spends the first
frame saying something that is not a number. A bar of the same
height reserves the digits instead, and the wording moves to a
live region so screen readers still hear it. 暂无法读取 stays a
word, because that is a settled state rather than progress. */}
{!visible && !summaryUnavailable ? (
<>
<p className="r6-home-figure">
<span
className="r6-home-figure-pending"
aria-hidden="true"
/>
</p>
<small role="status">{t("apps.readingCount")}</small>
</>
) : (
<>
<p className="r6-home-figure">
<strong className={visible ? "" : "r6-home-word"}>
{visible
? new Intl.NumberFormat(locale).format(
visible.memory_count,
)
: t("apps.unreadable")}
</strong>
{visible && t("apps.unit") ? (
<i>{t("apps.unit")}</i>
) : null}
</p>
<small>{t("apps.scope")}</small>
</>
)}
<p className="r6-home-figure">
<strong>
{new Intl.NumberFormat(locale).format(
visible?.memory_count ?? 0,
)}
</strong>
{t("apps.unit") ? <i>{t("apps.unit")}</i> : null}
</p>
<small>
{visible
? summaryStale
? t("apps.showingPrevious")
: t("apps.scope")
: summaryUnavailable
? t("apps.countWillUpdate")
: summaryFailures
? t("apps.startingRetry")
: t("apps.readingCount")}
</small>
</div>
<div>
<p>{t("apps.processing")}</p>
@@ -867,12 +857,16 @@ export function ApplicationsHome({
<p>{t("apps.recentUpdate")}</p>
<strong className={time ? "r6-home-time" : "r6-home-word"}>
{time ??
(visible ? t("apps.notGenerated") : t("apps.unchecked"))}
(visible
? t("apps.notGenerated")
: t("apps.checkingRecent"))}
</strong>
<small>
{time
? `${latestClock?.today ? `${t("apps.today")}· ` : ""}${t("apps.saved")}`
: t("apps.separate")}
: visible
? t("apps.separate")
: t("apps.recentWillUpdate")}
</small>
</div>
</div>
@@ -972,7 +966,9 @@ export function ApplicationsHome({
.join(" · ")
: activity === "importing"
? t("apps.savingConversations")
: t("apps.waitingMemories")}
: activity === "loading"
? t("apps.recentWillUpdate")
: t("apps.waitingMemories")}
</small>
</section>
{(activity === "unavailable" ||
+363 -9
View File
@@ -13,7 +13,6 @@ import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@@ -259,6 +258,40 @@ it("OBS-15: switching memory shows a read in progress instead of the previous me
expect(screen.getByRole("status").textContent).toContain("正在读取这条记忆");
});
it("shows a stable loading surface when the selected memory is read again", async () => {
const view = render(<Host simplified />);
await act(async () => {});
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
view.rerender(<Host simplified deferMemoryId="m0" />);
await userEvent.click(
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
"button",
{ name: /第一条/ },
),
);
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
expect(screen.getByRole("status").textContent).toContain("正在读取这条记忆");
expect(document.querySelector(".map-detail-loading")).toBeTruthy();
});
it("shows retry instead of stale content when the selected memory reread fails", async () => {
const view = render(<Host simplified />);
await act(async () => {});
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
view.rerender(<Host simplified failMemoryId="m0" />);
await userEvent.click(
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
"button",
{ name: /第一条/ },
),
);
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
expect(screen.getByRole("alert").textContent).toContain(
"这条记忆的原文暂时无法读取",
);
expect(screen.getByRole("button", { name: "重试读取原文" })).toBeTruthy();
});
it("keeps the detail header outside the scrolling body and resets detail scroll on selection", async () => {
const { container } = render(
<Host simplified sources={[importedSources[0]]} />,
@@ -269,6 +302,14 @@ it("keeps the detail header outside the scrolling body and resets detail scroll
const body = container.querySelector<HTMLElement>(".map-inspector-body");
expect(heading?.nextElementSibling).toBe(body);
if (!body) throw new Error("Memory detail scroll body is missing");
body.scrollTop = 90;
await userEvent.click(
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
"button",
{ name: /第一条/ },
),
);
expect(body.scrollTop).toBe(0);
body.scrollTop = 180;
await userEvent.click(
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
@@ -348,6 +389,8 @@ function Host({
processing = null,
loaded = true,
deferMemoryId,
failMemoryId,
graphValue = graph,
onViewHistory,
onConfigureModel = () => {},
libraryView,
@@ -363,11 +406,17 @@ function Host({
processing?: MemoryProcessingSummary | null;
loaded?: boolean;
deferMemoryId?: string;
failMemoryId?: string;
graphValue?: MemoryGraphResponse;
onViewHistory?(): void;
onConfigureModel?(): void;
libraryView?: Parameters<typeof MemoryMap>[0]["libraryView"];
} = {}) {
const [detail, setDetail] = useState<MemoryDetail | null>(null);
const [detailLoad, setDetailLoad] = useState<{
memoryId: string;
status: "loading" | "failed";
} | null>(null);
return (
<MemoryMap
simplified={simplified}
@@ -375,7 +424,7 @@ function Host({
hidden={hidden}
overlayBlocked={overlayBlocked}
memories={items}
graph={graph}
graph={graphValue}
sources={sources}
arrival={arrival}
onViewHistory={onViewHistory}
@@ -389,14 +438,25 @@ function Host({
}
: null
}
detailLoad={detailLoad}
draft={detail?.memory.content ?? ""}
busy={false}
answer={null}
streaming={null}
requestIds={{ current: new Map() }}
onOpenMemory={(id) => {
if (failMemoryId && id === failMemoryId) {
setDetail(null);
setDetailLoad({ memoryId: id, status: "failed" });
return;
}
// Lets a test leave one read unanswered.
if (deferMemoryId && id === deferMemoryId) return;
if (deferMemoryId && id === deferMemoryId) {
setDetail(null);
setDetailLoad({ memoryId: id, status: "loading" });
return;
}
setDetailLoad(null);
setDetail({
memory: items.find((m) => m.memory_id === id) ?? items[0],
evidence: [
@@ -414,7 +474,10 @@ function Host({
index_sync: null,
});
}}
onCloseDetail={() => setDetail(null)}
onCloseDetail={() => {
setDetail(null);
setDetailLoad(null);
}}
onAsk={() => {}}
onDraft={() => {}}
onSave={() => {}}
@@ -467,6 +530,57 @@ it("does not present an unverified empty page as zero memories", () => {
expect(screen.queryByText(/0 条/)).toBeNull();
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
});
it("does not show the previous source graph while the new scope's graph loads", () => {
render(
<Host
simplified
libraryView={{
...pagedView({
items: memories,
total: memories.length,
offset: 0,
limit: 20,
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
}),
query: "项目",
graphLoading: true,
}}
/>,
);
expect(screen.queryByTestId("canvas")).toBeNull();
expect(screen.getByText("正在读取记忆地图…")).toBeTruthy();
// The list keeps its own state: graph loading is not page staleness.
expect(screen.queryByText("正在核对来源…")).toBeNull();
});
it("shows a retry instead of collapsing a failed graph to the current page", async () => {
const onRetry = vi.fn();
render(
<Host
simplified
libraryView={{
...pagedView({
items: memories,
total: memories.length,
offset: 0,
limit: 20,
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
}),
graphFailed: true,
onRetry,
}}
/>,
);
expect(screen.queryByTestId("canvas")).toBeNull();
expect(screen.getByText("记忆暂时无法读取")).toBeTruthy();
expect(screen.getByRole("status").textContent).toBe("记忆暂时无法读取");
await userEvent.click(
screen.getByRole("button", { name: "重新读取地图记忆" }),
);
expect(onRetry).toHaveBeenCalledOnce();
});
it("starts with receipt-backed source grouping, keeping topics secondary", () => {
render(<Host />);
expect(
@@ -478,6 +592,118 @@ it("starts with receipt-backed source grouping, keeping topics secondary", () =>
expect(screen.getByRole("button", { name: "Codex2" })).toBeDefined();
});
it("restores the full graph scope when the source filter returns to all applications", async () => {
const workbuddyMemory = {
...memories[1],
memory_id: "workbuddy-memory",
content: "较早的 WorkBuddy 记忆",
};
const allGraph = {
total_memories: 2,
projected_memories: 2,
hy_graph_available: false,
nodes: [
graph.nodes.find((node) => node.node_id === "memory:m0"),
graph.nodes.find((node) => node.node_id === "source:codex"),
{
node_id: "memory:workbuddy-memory",
node_type: "memory",
memory_id: "workbuddy-memory",
label: workbuddyMemory.content,
},
{
node_id: "source:workbuddy",
node_type: "source",
source_id: "workbuddy",
label: "WorkBuddy",
},
].filter(Boolean),
edges: [
{
edge_id: "cursor-memory",
source_node_id: "source:codex",
target_node_id: "memory:m0",
relation: "evidence_source",
origin: "provenance",
},
{
edge_id: "workbuddy-memory",
source_node_id: "source:workbuddy",
target_node_id: "memory:workbuddy-memory",
relation: "evidence_source",
origin: "provenance",
},
],
} as MemoryGraphResponse;
const cursorGraph = {
...allGraph,
total_memories: 1,
projected_memories: 1,
nodes: allGraph.nodes.filter(
(node) => node.node_id === "memory:m0" || node.node_id === "source:codex",
),
edges: [allGraph.edges[0]],
};
function ScopeHarness() {
const [source, setSource] = useState("codex");
return (
<Host
simplified
items={[memories[0]]}
graphValue={source ? cursorGraph : allGraph}
sources={
[
{
source_id: "codex",
display_name: "Cursor",
authorized: true,
},
{
source_id: "workbuddy",
display_name: "WorkBuddy",
authorized: true,
},
{
source_id: "retired",
display_name: "Retired",
authorized: false,
},
] as SourceResponse[]
}
libraryView={{
...pagedView({
items: [memories[0]],
total: source ? 1 : 2,
offset: 0,
limit: 20,
source_ids_by_memory: { m0: ["codex"] },
}),
source,
onSource: setSource,
}}
/>
);
}
render(<ScopeHarness />);
expect(
screen.queryByRole("button", {
name: "图中选择 较早的 WorkBuddy 记忆",
}),
).toBeNull();
await userEvent.click(screen.getByRole("combobox", { name: "来源" }));
await userEvent.click(screen.getByRole("option", { name: "全部应用" }));
expect(
screen.getByRole("button", {
name: "图中选择 较早的 WorkBuddy 记忆",
}),
).toBeTruthy();
expect(
screen.getByRole("button", { name: "图中选择 WorkBuddy" }),
).toBeTruthy();
});
function pagedView(
page: Parameters<typeof MemoryMap>[0]["libraryView"] extends infer V
? V extends { page: infer P }
@@ -580,6 +806,8 @@ it("keeps pagination and current detail stable while the next page is loading",
const libraryView = { ...pagedView(page), onPage };
const view = render(<Host simplified libraryView={libraryView} />);
const list = screen.getByRole("list", { name: "地图记忆列表" });
const statusSlot = document.querySelector(".map-list-status");
expect(statusSlot?.getAttribute("data-visible")).toBe("false");
list.scrollTop = 160;
const pager = screen.getByRole("navigation", { name: "地图记忆分页" });
await userEvent.click(
@@ -594,6 +822,8 @@ it("keeps pagination and current detail stable while the next page is loading",
.getByRole("navigation", { name: "地图记忆分页" })
.getAttribute("aria-busy"),
).toBe("true");
expect(document.querySelector(".map-list-status")).toBe(statusSlot);
expect(statusSlot?.getAttribute("data-visible")).toBe("true");
expect(
(
screen.getByRole("button", {
@@ -629,10 +859,131 @@ it("keeps pagination and current detail stable while the next page is loading",
/>,
);
expect(list.scrollTop).toBe(0);
expect(document.querySelector(".map-list-status")).toBe(statusSlot);
expect(statusSlot?.getAttribute("data-visible")).toBe("false");
expect(within(list).getByText("下一页第 1 条")).toBeTruthy();
await waitFor(() =>
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull(),
expect(
await screen.findByRole("heading", { name: "下一页第 1 条" }),
).toBeTruthy();
expect(
within(list)
.getByRole("button", { name: /下一页第 1 条/ })
.getAttribute("aria-pressed"),
).toBe("true");
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
});
it("keeps the page intent through a failed read and selects the first row after retry", async () => {
const onPage = vi.fn();
const onRetry = vi.fn();
const page = {
items: memories,
total: 202,
offset: 0,
limit: 100,
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
};
const base = { ...pagedView(page), onPage, onRetry };
const view = render(<Host simplified libraryView={base} />);
await act(async () => {});
await userEvent.click(screen.getByRole("button", { name: "下一页记忆" }));
expect(
within(screen.getByRole("list", { name: "地图记忆列表" }))
.getByRole("button", { name: /第一条/ })
.hasAttribute("disabled"),
).toBe(true);
view.rerender(
<Host
simplified
libraryView={{
...base,
loading: false,
failed: true,
requestedOffset: 100,
}}
/>,
);
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
await userEvent.click(
screen.getByRole("button", { name: "重新读取地图记忆" }),
);
expect(onRetry).toHaveBeenCalledOnce();
expect(
within(screen.getByRole("list", { name: "地图记忆列表" }))
.getByRole("button", { name: /第一条/ })
.hasAttribute("disabled"),
).toBe(true);
expect(
(
screen.getByRole("button", {
name: "搜索",
}) as HTMLButtonElement
).disabled,
).toBe(true);
const nextItems = memories.map((memory, index) => ({
...memory,
memory_id: `retry-${index}`,
content: `重试页第 ${index + 1} 条`,
}));
view.rerender(
<Host
simplified
items={nextItems}
libraryView={{
...base,
requestedOffset: 100,
page: {
...page,
items: nextItems,
offset: 100,
source_ids_by_memory: {
"retry-0": ["codex"],
"retry-1": ["codex"],
},
},
loading: false,
failed: false,
}}
/>,
);
expect(
await screen.findByRole("heading", { name: "重试页第 1 条" }),
).toBeTruthy();
});
it("returns to the shown page when a failed page request is cancelled by a row click", async () => {
const onPage = vi.fn();
const page = {
items: memories,
total: 202,
offset: 0,
limit: 100,
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
};
const base = { ...pagedView(page), onPage };
const view = render(<Host simplified libraryView={base} />);
await act(async () => {});
await userEvent.click(screen.getByRole("button", { name: "下一页记忆" }));
view.rerender(
<Host
simplified
libraryView={{
...base,
failed: true,
requestedOffset: 100,
}}
/>,
);
await userEvent.click(
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
"button",
{ name: /第二条/ },
),
);
expect(onPage.mock.calls).toEqual([[100], [0]]);
expect(await screen.findByRole("heading", { name: "第二条" })).toBeTruthy();
});
it("names the SDK's no-source bucket 未识别来源, apart from a source under review", () => {
@@ -712,8 +1063,10 @@ it("badges each listed memory with its scene and pages 20 at a time", () => {
expect((next as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(next);
expect(view.onPage).toHaveBeenCalledWith(40);
fireEvent.click(screen.getByRole("button", { name: "上一页记忆" }));
expect(view.onPage).toHaveBeenCalledWith(0);
const previous = screen.getByRole("button", { name: "上一页记忆" });
expect((previous as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(previous);
expect(view.onPage).not.toHaveBeenCalledWith(0);
});
it("says a filtered source is empty rather than that nothing matched", () => {
@@ -1238,7 +1591,8 @@ it("drops a pending source selection when that source is revoked", async () => {
}}
/>,
);
expect(screen.queryByRole("button", { name: "图中选择 Codex" })).toBeNull();
// Memories already distilled from a revoked source stay on the map, but the
// revoked source can no longer be the selection.
expect(screen.getByTestId("canvas").dataset.selected).not.toBe(
"source:codex",
);
+202 -38
View File
@@ -95,9 +95,13 @@ type Props = {
page: MemoryLibraryPage | null;
loading: boolean;
failed: boolean;
/** The graph for the current source/query scope has not arrived yet. */
graphLoading?: boolean;
graphFailed?: boolean;
stale: boolean;
settledSource?: string;
settledQuery?: string;
requestedOffset?: number;
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
unattributedSource?: string;
onQuery(value: string): void;
@@ -221,6 +225,15 @@ export function MemoryMap(props: Props) {
const [pendingSourceSelection, setPendingSourceSelection] = useState<
string | null
>(null);
const [pendingPageOffset, setPendingPageOffset] = useState<number | null>(
null,
);
const [pageRetrying, setPageRetrying] = useState(false);
const pageRetrySawLoading = useRef(false);
const [detailResetRevision, setDetailResetRevision] = useState(0);
const pageTransitioning =
!!library?.loading ||
(pendingPageOffset !== null && (!library?.failed || pageRetrying));
const [question, setQuestion] = useState("");
const [inspectorOpen, setInspectorOpen] = useState(false);
const [filterNotice, setFilterNotice] = useState("");
@@ -316,6 +329,31 @@ export function MemoryMap(props: Props) {
[active, group, search, serverPaged, axis, groupKey],
);
const projection = useMemo(() => {
// Until the new scope's graph arrives (or after it failed), drawing the
// previous scope's graph would show the wrong source.
if (serverPaged && (library?.graphLoading || library?.graphFailed))
return {
total_memories: library.page?.total ?? 0,
projected_memories: 0,
hy_graph_available: false,
nodes: [],
edges: [],
};
// Source filtering owns the graph scope, while pagination owns only the
// left-hand list window. Projecting the graph back down to the current
// 20-row page made "All applications" look like whichever source happened
// to dominate that page. Keyword search is intentionally page-scoped.
if (serverPaged && !library?.query) {
return (
baseGraph ??
projectMap(
graph,
active,
axis,
filtered.map((m) => m.memory_id),
)
);
}
const result = projectMap(
graph,
active,
@@ -347,7 +385,21 @@ export function MemoryMap(props: Props) {
confidence: null,
})),
};
}, [graph, axis, filtered, active, groupKey, search, sources, serverPaged]);
}, [
baseGraph,
graph,
axis,
filtered,
active,
groupKey,
search,
sources,
serverPaged,
library?.query,
library?.graphLoading,
library?.graphFailed,
library?.page?.total,
]);
const visibleGroupNode = groupNode
? (projection.nodes.find(
(node) =>
@@ -384,6 +436,7 @@ export function MemoryMap(props: Props) {
: selectedSource
? `source:${selectedSource.source_id}`
: (visibleGroupNode?.node_id ?? "empty");
const inspectorResetKey = `${inspectorIdentity}:${detailResetRevision}`;
const visibleDetail =
detail &&
(!activeId || activeId === detail.memory.memory_id) &&
@@ -395,10 +448,24 @@ export function MemoryMap(props: Props) {
const inspectorDetail =
visibleDetail ??
(activeId && lastDetail.current ? lastDetail.current : null);
const readingActiveDetail =
!!activeId &&
props.detailLoad?.memoryId === activeId &&
props.detailLoad.status === "loading";
const failedActiveDetail =
!!activeId &&
props.detailLoad?.memoryId === activeId &&
props.detailLoad.status === "failed";
const pick = useCallback(
(memoryId: string, scroll = true) => {
if (!active.some((memory) => memory.memory_id === memoryId)) return;
if (pendingPageOffset !== null && library?.failed && library.page)
library.onPage(library.page.offset);
setPendingSourceSelection(null);
setPendingPageOffset(null);
setPageRetrying(false);
pageRetrySawLoading.current = false;
setDetailResetRevision((current) => current + 1);
scrollSelection.current = scroll;
if (!filtered.some((memory) => memory.memory_id === memoryId)) {
setGroupKey("");
@@ -410,7 +477,7 @@ export function MemoryMap(props: Props) {
setInspectorOpen(true);
onOpenMemory(memoryId);
},
[active, filtered, onOpenMemory],
[active, filtered, library, onOpenMemory, pendingPageOffset],
);
useEffect(() => {
if (
@@ -438,6 +505,24 @@ export function MemoryMap(props: Props) {
pick,
selectedSource,
]);
useLayoutEffect(() => {
if (pendingPageOffset === null || !library || library.loading) return;
if (library.failed || library.stale) return;
const expectedOffset = library.requestedOffset ?? pendingPageOffset;
if (library.page?.offset !== expectedOffset) return;
setPendingPageOffset(null);
setPageRetrying(false);
pageRetrySawLoading.current = false;
const first = active[0];
if (first) {
pick(first.memory_id, false);
return;
}
setSelectedId(null);
setGroupNode(null);
setInspectorOpen(false);
onCloseDetail();
}, [active, library, onCloseDetail, pendingPageOffset, pick]);
useEffect(() => {
if (!props.simplified || openedDefault.current || overlaysSuspended) return;
if (library?.loading && filtered.length === 0) return;
@@ -455,6 +540,17 @@ export function MemoryMap(props: Props) {
detail,
pick,
]);
useEffect(() => {
if (!pageRetrying || !library) return;
if (library.loading) {
pageRetrySawLoading.current = true;
return;
}
if (pageRetrySawLoading.current && library.failed) {
pageRetrySawLoading.current = false;
setPageRetrying(false);
}
}, [library, pageRetrying]);
useEffect(() => {
if (!activeId || !scrollSelection.current) return;
scrollSelection.current = false;
@@ -474,9 +570,9 @@ export function MemoryMap(props: Props) {
useLayoutEffect(() => {
// Detail content owns its own scrollport. A newly selected memory must
// start at its heading instead of inheriting the previous inspector view.
if (inspectorIdentity === "empty") return;
if (inspectorResetKey.startsWith("empty:")) return;
if (inspectorBody.current) inspectorBody.current.scrollTop = 0;
}, [inspectorIdentity]);
}, [inspectorResetKey]);
const settledPageOffset = library?.page?.offset;
useLayoutEffect(() => {
// A settled page is a new reading surface, not a continuation of the
@@ -489,12 +585,16 @@ export function MemoryMap(props: Props) {
setSelectedId(null);
setGroupNode(null);
setPendingSourceSelection(null);
setPendingPageOffset(null);
setPageRetrying(false);
pageRetrySawLoading.current = false;
setInspectorOpen(false);
onCloseDetail();
}, [onCloseDetail]);
useEffect(() => {
// A background map does not own the shared inspector in another mode.
if (overlaysSuspended || library?.loading) return;
if (overlaysSuspended || library?.loading || pendingPageOffset !== null)
return;
if (
requestedId &&
!active.some((memory) => memory.memory_id === requestedId)
@@ -514,6 +614,7 @@ export function MemoryMap(props: Props) {
clear,
overlaysSuspended,
library?.loading,
pendingPageOffset,
]);
function closeInspector() {
if (!compact) {
@@ -523,6 +624,14 @@ export function MemoryMap(props: Props) {
setPendingSourceSelection(null);
setInspectorOpen(false);
}
function retryLibrary() {
if (!library) return;
if (pendingPageOffset !== null) {
pageRetrySawLoading.current = false;
setPageRetrying(true);
}
library.onRetry();
}
function chooseGroup(key: string) {
setGroupNode(null);
if (library) {
@@ -577,6 +686,17 @@ export function MemoryMap(props: Props) {
? text("未识别来源")
: null) ??
text("全部记忆");
const libraryStatus = !library
? ""
: library.failed
? text("读取失败")
: library.stale
? text("正在核对来源…")
: pageTransitioning
? refreshing && library.page?.total !== 0
? text("正在核对…")
: text("正在读取…")
: "";
if (
props.simplified &&
(!library ||
@@ -777,21 +897,20 @@ export function MemoryMap(props: Props) {
<button
type="submit"
className="map-search-submit"
disabled={library.loading || hidden}
disabled={pageTransitioning || hidden}
>
{text("搜索")}
</button>
) : null}
</form>
{library && (library.loading || library.stale || library.failed) ? (
<p className="map-list-status" aria-live="polite">
{library.failed
? text("读取失败")
: library.stale
? text("正在核对来源…")
: refreshing && library.page?.total !== 0
? text("正在核对…")
: text("正在读取…")}
{library ? (
<p
className="map-list-status"
aria-live="polite"
aria-hidden={!libraryStatus || undefined}
data-visible={libraryStatus ? "true" : "false"}
>
{libraryStatus || "\u00a0"}
</p>
) : null}
{!props.simplified && (
@@ -861,6 +980,7 @@ export function MemoryMap(props: Props) {
type="button"
className="map-memory-item"
aria-pressed={activeId === m.memory_id}
disabled={pageTransitioning}
onClick={() => pick(m.memory_id)}
>
<span className={`map-state ${m.status}`} aria-hidden="true" />
@@ -914,7 +1034,7 @@ export function MemoryMap(props: Props) {
{library?.failed && (
<div role="alert">
<p>{text("记忆读取失败,请重试。")}</p>
<R6Button onClick={library.onRetry}>
<R6Button onClick={retryLibrary}>
{text("重新读取地图记忆")}
</R6Button>
</div>
@@ -949,7 +1069,7 @@ export function MemoryMap(props: Props) {
!library.stale && (
<nav
aria-label={text("地图记忆分页")}
aria-busy={library.loading || undefined}
aria-busy={pageTransitioning || undefined}
className="map-pagination"
>
<button
@@ -957,11 +1077,16 @@ export function MemoryMap(props: Props) {
className="quiet map-tool"
aria-label={text("上一页记忆")}
title={text("上一页记忆")}
disabled={library.loading || library.page.offset === 0}
disabled={pageTransitioning || library.page.offset === 0}
onClick={() => {
if (axis === "topic") setGroupKey("");
if (library.page)
library.onPage(library.page.offset - library.page.limit);
if (library.page) {
const offset = library.page.offset - library.page.limit;
setPageRetrying(false);
pageRetrySawLoading.current = false;
setPendingPageOffset(offset);
library.onPage(offset);
}
}}
>
<ChevronLeft size={16} aria-hidden="true" />
@@ -987,13 +1112,18 @@ export function MemoryMap(props: Props) {
aria-label={text("下一页记忆")}
title={text("下一页记忆")}
disabled={
library.loading ||
pageTransitioning ||
library.page.offset + library.page.limit >= library.page.total
}
onClick={() => {
if (axis === "topic") setGroupKey("");
if (library.page)
library.onPage(library.page.offset + library.page.limit);
if (library.page) {
const offset = library.page.offset + library.page.limit;
setPageRetrying(false);
pageRetrySawLoading.current = false;
setPendingPageOffset(offset);
library.onPage(offset);
}
}}
>
<ChevronRight size={16} aria-hidden="true" />
@@ -1059,22 +1189,33 @@ export function MemoryMap(props: Props) {
) : (
<div className="map-empty-stage">
<Network size={32} strokeWidth={1} />
<p>
<p
{...(library?.graphFailed || library?.graphLoading
? { role: "status", "aria-live": "polite" as const }
: {})}
>
{library?.stale
? text("可在沉淀记忆后查看视图")
: library?.failed
: library?.graphFailed || library?.failed
? text("记忆暂时无法读取")
: library?.page &&
(library.query || library.source || groupKey)
? text("当前筛选没有可显示的记忆")
: !loaded
? text("正在读取记忆…")
: active.length
? text("当前筛选没有可显示的记忆")
: arrival.importedMessages > 0
? text("已保存的对话还未生成记忆")
: text("还没有可显示的记忆")}
: library?.graphLoading
? text("正在读取记忆地图…")
: library?.page &&
(library.query || library.source || groupKey)
? text("当前筛选没有可显示的记忆")
: !loaded
? text("正在读取记忆…")
: active.length
? text("当前筛选没有可显示的记忆")
: arrival.importedMessages > 0
? text("已保存的对话还未生成记忆")
: text("还没有可显示的记忆")}
</p>
{library?.graphFailed ? (
<R6Button onClick={library.onRetry}>
{text("重新读取地图记忆")}
</R6Button>
) : null}
</div>
)}
</div>
@@ -1167,7 +1308,7 @@ export function MemoryMap(props: Props) {
<MemoryEvidencePanel
open={inspectorOpen && !!(activeId || visibleGroupNode)}
suspended={overlaysSuspended}
scrollResetKey={activeId ?? visibleGroupNode?.node_id ?? "empty"}
scrollResetKey={inspectorResetKey}
onClose={closeInspector}
>
<header className="map-inspector-heading">
@@ -1202,7 +1343,7 @@ export function MemoryMap(props: Props) {
<button
type="button"
className="secondary"
onClick={library.onRetry}
onClick={retryLibrary}
>
{text("重试")}
</button>
@@ -1223,8 +1364,17 @@ export function MemoryMap(props: Props) {
</div>
) : props.simplified && inspectorDetail ? (
<div
key={`${inspectorDetail.memory.memory_id}:${detailResetRevision}:${
readingActiveDetail
? "loading"
: failedActiveDetail
? "failed"
: "ready"
}`}
className="map-detail-surface"
aria-busy={
refreshing ||
readingActiveDetail ||
inspectorDetail.memory.memory_id !== activeId ||
undefined
}
@@ -1249,7 +1399,21 @@ export function MemoryMap(props: Props) {
until the new one arrived, which read as "nothing happened"
for two or three seconds. A read is not a value: show that a
read is happening instead. */}
{activeId && inspectorDetail.memory.memory_id !== activeId ? (
{failedActiveDetail ? (
<div className="map-detail-loading">
<p role="alert">
{text("这条记忆的原文暂时无法读取,选择已保留。")}
</p>
<button
type="button"
className="secondary"
onClick={() => activeId && onOpenMemory(activeId)}
>
{text("重试读取原文")}
</button>
</div>
) : readingActiveDetail ||
(activeId && inspectorDetail.memory.memory_id !== activeId) ? (
<div className="map-detail-loading">
<div className="map-detail-skeleton" aria-hidden="true">
<i />
@@ -5,6 +5,7 @@ import type {
SdkMemoryRecord,
} from "@one-memory/contracts";
import {
act,
cleanup,
fireEvent,
render,
@@ -63,6 +64,9 @@ vi.mock("./MemoryMap", () => ({
source_ids_by_memory?: Record<string, string[]>;
offset: number;
} | null;
graphLoading?: boolean;
graphFailed?: boolean;
stale: boolean;
unattributedSource?: string;
onSource(value: string): void;
onQuery(value: string): void;
@@ -91,6 +95,13 @@ vi.mock("./MemoryMap", () => ({
<span data-testid="map-busy">{String(busy)}</span>
<span data-testid="map-detail">{detail?.memory.memory_id ?? ""}</span>
<span data-testid="map-offset">{libraryView?.page?.offset ?? ""}</span>
<span data-testid="map-stale">{String(libraryView?.stale)}</span>
<span data-testid="map-graph-loading">
{String(libraryView?.graphLoading)}
</span>
<span data-testid="map-graph-failed">
{String(libraryView?.graphFailed)}
</span>
<button type="button" onClick={() => onOpenMemory("e1")}>
open e1
</button>
@@ -133,6 +144,15 @@ vi.mock("./MemoryMap", () => ({
>
pick workbuddy
</button>
<button
type="button"
onClick={() => {
libraryView?.onSource("");
libraryView?.resetPage();
}}
>
pick all
</button>
</div>
),
}));
@@ -214,11 +234,23 @@ it("draws the map from SDK memories on SDK authority, without legacy reads", asy
expect.any(AbortSignal),
);
expect(api.sdkGraph).toHaveBeenCalledWith(
{ source: undefined, offset: 0, limit: 100 },
{ source: undefined, offset: 0, limit: 300 },
expect.any(AbortSignal),
);
});
it("keeps a graph read failure explicit instead of presenting the list page as the graph", async () => {
sdkAuthority();
api.sdkGraph.mockRejectedValue(new Error("graph unavailable"));
render(<MemorySpace simplified active />);
await waitFor(() =>
expect(screen.getByTestId("map-loaded").textContent).toBe("true"),
);
expect(screen.getByTestId("map-graph-failed").textContent).toBe("true");
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
});
it("keeps rows, sources, count and graph on the one filtered page", async () => {
sdkAuthority();
render(<MemorySpace simplified active />);
@@ -251,12 +283,150 @@ it("keeps rows, sources, count and graph on the one filtered page", async () =>
expect.any(AbortSignal),
);
expect(api.sdkGraph).toHaveBeenLastCalledWith(
{ source: "workbuddy", offset: 0, limit: 100 },
{ source: "workbuddy", offset: 0, limit: 300 },
expect.any(AbortSignal),
);
expect(await screen.findByText(/566/)).toBeTruthy();
});
it("hides the previous source graph until the all-applications graph settles", async () => {
sdkAuthority();
render(<MemorySpace simplified active />);
await waitFor(() =>
expect(screen.getByTestId("map-nodes").textContent).toBe("3"),
);
const workbuddyPage = {
memories: [{ ...record("w1"), source: "workbuddy" }],
total: 1,
limit: 20,
offset: 0,
};
const workbuddyGraph: MemoryGraphResponse = {
total_memories: 1,
projected_memories: 1,
nodes: [
{
node_id: "memory:w1",
node_type: "memory",
label: "w1",
memory_id: "w1",
source_id: null,
kind: null,
status: "active",
confidence: null,
},
{
node_id: "source:workbuddy",
node_type: "source",
label: "WorkBuddy",
source_id: "workbuddy",
memory_id: null,
kind: null,
status: null,
confidence: null,
},
],
edges: [],
hy_graph_available: false,
};
api.sdkLibrary.mockResolvedValue(workbuddyPage);
api.sdkGraph.mockResolvedValue(workbuddyGraph);
fireEvent.click(screen.getByText("pick workbuddy"));
await waitFor(() =>
expect(screen.getByTestId("map-nodes").textContent).toBe("2"),
);
const allPage = {
memories: [record("a"), { ...record("w1"), source: "workbuddy" }],
total: 2,
limit: 20,
offset: 0,
};
const allGraph: MemoryGraphResponse = {
...workbuddyGraph,
total_memories: 2,
projected_memories: 2,
nodes: [
...workbuddyGraph.nodes,
{
node_id: "memory:a",
node_type: "memory",
label: "a",
memory_id: "a",
source_id: null,
kind: null,
status: "active",
confidence: null,
},
{
node_id: "source:cursor",
node_type: "source",
label: "Cursor",
source_id: "cursor",
memory_id: null,
kind: null,
status: null,
confidence: null,
},
],
};
let releasePage: (value: typeof allPage) => void = () => {};
let releaseGraph: (value: MemoryGraphResponse) => void = () => {};
api.sdkLibrary.mockReturnValueOnce(
new Promise((resolve) => {
releasePage = resolve;
}),
);
api.sdkGraph.mockReturnValueOnce(
new Promise((resolve) => {
releaseGraph = resolve;
}),
);
fireEvent.click(screen.getByText("pick all"));
await waitFor(() =>
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
);
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
// Waiting for the graph is not page staleness: the list stays usable.
expect(screen.getByTestId("map-stale").textContent).toBe("false");
await act(async () => {
releasePage(allPage);
releaseGraph(allGraph);
});
await waitFor(() =>
expect(screen.getByTestId("map-nodes").textContent).toBe("4"),
);
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false");
const searchPage = {
memories: [record("search-result")],
total: 1,
limit: 20,
offset: 0,
};
let releaseSearch: (value: typeof searchPage) => void = () => {};
api.sdkLibrary.mockReturnValueOnce(
new Promise((resolve) => {
releaseSearch = resolve;
}),
);
const graphCalls = api.sdkGraph.mock.calls.length;
fireEvent.click(screen.getByText("search deploy"));
await waitFor(() =>
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
);
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
expect(api.sdkGraph).toHaveBeenCalledTimes(graphCalls);
await act(async () => releaseSearch(searchPage));
await waitFor(() =>
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false"),
);
});
it("opens an SDK memory in place instead of switching away from the map", async () => {
sdkAuthority();
api.sdkMemory.mockResolvedValue(record("a"));
+44 -23
View File
@@ -166,11 +166,14 @@ export function MemorySpace({
// The SDK library is no longer where a memory opens from the map; the id is
// kept so the panel still has a focus target for its own tests.
const [sdkFocusedMemoryId] = useState<string | null>(null);
// The list, its count, its source and time labels and the graph are all one
// page: the library page and the graph for that same source and window,
// read together. Reading the graph from a different, unfiltered window made
// rows, sources and totals disagree.
const [sdkGraph, setSdkGraph] = useState<MemoryGraphResponse | null>(null);
// The graph covers the active source scope (up to its visual cap), while the
// list pages through that scope. Keep source+query with the value so a
// filter change cannot paint the previous filter's graph during the new read.
const [sdkGraph, setSdkGraph] = useState<{
scope: string;
status: "ready" | "failed";
value: MemoryGraphResponse | null;
} | null>(null);
// Bumping this re-reads the page and graph after an edit or a delete.
const [sdkMapRevision, setSdkMapRevision] = useState(0);
const bumpSdkMap = useCallback(() => setSdkMapRevision((v) => v + 1), []);
@@ -191,6 +194,17 @@ export function MemorySpace({
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
const sdkRequestedScope = JSON.stringify([
sdkLibraryQuery.trim(),
sdkLibrarySource,
]);
const sdkGraphSettled = sdkGraph?.scope === sdkRequestedScope;
const visibleSdkGraph =
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "ready"
? sdkGraph.value
: null;
const sdkGraphFailed =
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "failed";
// A keyword search pages through one server-side result window. The id is
// kept for the query and source it was searched with; a new query or
// source starts without one, so the server searches afresh.
@@ -233,22 +247,26 @@ export function MemorySpace({
request.signal,
),
// The overlay is decoration: the list stands without it. It reads
// chat memories only, newest first; a mixed page's chat rows are
// among the newest offset+page chat memories, so that prefix holds
// every relation the page can draw.
getSdkMemoryGraph(
{
source,
offset: 0,
limit: query
? 100
: Math.min(
300,
Math.max(100, sdkLibraryOffset + SDK_MEMORY_PAGE_SIZE),
),
},
request.signal,
).catch(() => null),
// the full active/source-filtered scene up to the API's visual cap.
// Reading only the first list-page prefix made "All applications"
// look identical to Cursor whenever newer Cursor memories occupied
// that prefix.
query
? Promise.resolve({
status: "ready" as const,
value: null,
})
: getSdkMemoryGraph(
{
source,
offset: 0,
limit: 300,
},
request.signal,
).then(
(value) => ({ status: "ready" as const, value }),
() => ({ status: "failed" as const, value: null }),
),
]);
if (cancelled) return;
if (sdkSearchWindow.current.key === windowKey)
@@ -265,7 +283,7 @@ export function MemorySpace({
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
});
setSdkGraph(graph);
setSdkGraph({ scope: windowKey, ...graph });
} catch {
if (cancelled || request.signal.aborted) return;
setSdkLibrary((current) => ({
@@ -1026,9 +1044,12 @@ export function MemorySpace({
page: sdkLibrary.page,
loading: sdkLibrary.loading,
failed: sdkLibrary.failed,
graphLoading: !sdkGraphSettled,
graphFailed: sdkGraphFailed,
stale: false,
settledSource: sdkLibrary.settledSource,
settledQuery: sdkLibrary.settledQuery,
requestedOffset: sdkLibraryOffset,
unattributedSource: UNATTRIBUTED_SOURCE_ID,
onQuery: setSdkLibraryQuery,
onSource: setSdkLibrarySource,
@@ -1040,7 +1061,7 @@ export function MemorySpace({
}
hidden={!active || displayedMode !== "map"}
memories={sdkLibrary.page?.items ?? []}
graph={sdkGraph}
graph={visibleSdkGraph}
sources={sources}
processing={processing}
arrival={memoryArrival}
@@ -136,6 +136,70 @@ it("splits long memory text into a bounded heading and readable body", () => {
"selected-memory-body",
);
});
it("renders an SDK experience from its structured task and solution", () => {
const task =
"review an uncommitted diff for privacy and correctness blockers";
const solution = `Reusable checklist:\n1. Run \`git diff --check\`.\n2. Inspect ${"/very-long-path".repeat(20)}.`;
render(
<SelectedMemoryDetail
detail={{
...detail,
memory: {
...detail.memory,
content: `${task}\n${solution}`,
revision: 0,
},
evidence: [],
sdk_status: "active",
sdk_ref: {
scene: "coding",
kind: "experience",
variant: "coding",
},
sdk_task: task,
sdk_solution: solution,
}}
sources={[]}
/>,
);
expect(screen.getByRole("heading", { name: task })).toBeTruthy();
const body = document.querySelector(".selected-memory-body");
expect(body?.textContent).toBe(solution);
expect(
screen
.getByRole("heading", { name: task })
.closest("[data-memory-kind]")
?.getAttribute("data-memory-kind"),
).toBe("experience");
});
it("does not apply experience fields to a fact memory", () => {
const content = `${"事实".repeat(40)}。补充说明`;
render(
<SelectedMemoryDetail
detail={{
...detail,
memory: { ...detail.memory, content, revision: 0 },
evidence: [],
sdk_status: "active",
sdk_ref: {
scene: "working",
kind: "fact",
variant: "working",
},
sdk_task: "不应显示的任务标题",
sdk_solution: "不应显示的经验正文",
}}
sources={[]}
/>,
);
expect(screen.queryByText("不应显示的任务标题")).toBeNull();
expect(screen.queryByText("不应显示的经验正文")).toBeNull();
expect(
document
.querySelector(".selected-memory-detail")
?.getAttribute("data-memory-kind"),
).toBe("memory");
});
it.each([
[
"a late sentence end",
+20 -5
View File
@@ -65,7 +65,7 @@ export function SelectedMemoryDetail({
// (CJK text has none, so it breaks at the limit itself).
const punctuation = memory.content.search(/[。!?!?\n]/);
const space = memory.content.lastIndexOf(" ", HEADING_LIMIT);
const split =
const fallbackSplit =
punctuation > 0 && punctuation < HEADING_LIMIT
? punctuation + 1
: memory.content.length > HEADING_LIMIT
@@ -73,11 +73,26 @@ export function SelectedMemoryDetail({
? space
: HEADING_LIMIT
: -1;
const heading =
split > 0 ? memory.content.slice(0, split).trim() : memory.content;
const body = split > 0 ? memory.content.slice(split).trim() : "";
const fallbackHeading =
fallbackSplit > 0
? memory.content.slice(0, fallbackSplit).trim()
: memory.content;
const fallbackBody =
fallbackSplit > 0 ? memory.content.slice(fallbackSplit).trim() : "";
const experience =
detail.sdk_ref?.kind === "experience" &&
Boolean(detail.sdk_task?.trim() || detail.sdk_solution?.trim());
const heading = experience
? detail.sdk_task?.trim() || fallbackHeading
: fallbackHeading;
const body = experience
? detail.sdk_solution?.trim() || (detail.sdk_task ? "" : fallbackBody)
: fallbackBody;
return (
<article className="selected-memory-detail">
<article
className="selected-memory-detail"
data-memory-kind={experience ? "experience" : "memory"}
>
<p className="selected-memory-status">
{statusLabel}
{!detail.sdk_status &&
+16 -2
View File
@@ -788,12 +788,16 @@ button.rail-button.active::before {
}
.map-list-status {
flex: none;
min-height: 28px;
margin: 0;
padding: 4px;
color: var(--tx3);
font-size: 12px;
line-height: 20px;
}
.map-list-status[data-visible="false"] {
visibility: hidden;
}
.map-filter-notice {
font-size: 12px;
line-height: 1.65;
@@ -879,6 +883,10 @@ button.rail-button.active::before {
background: #fdfefe;
border-color: #c7d3dc;
}
.map-memory-item:disabled {
opacity: 1;
cursor: default;
}
.map-state {
width: 5px;
height: 5px;
@@ -1142,6 +1150,7 @@ button.map-tool {
}
.map-detail-loading {
display: grid;
min-height: 280px;
gap: 10px;
padding: 4px 0;
}
@@ -1152,8 +1161,13 @@ button.map-tool {
}
@keyframes map-detail-enter {
from {
opacity: 0;
transform: translateY(4px);
opacity: 0.35;
}
}
@media (prefers-reduced-motion: no-preference) {
.map-detail-surface,
.map-detail-loading {
animation: map-detail-enter 140ms cubic-bezier(0, 0, 0.2, 1);
}
}
.map-inspector .wb-detail {
+2
View File
@@ -263,12 +263,14 @@
display: block;
flex: none;
min-width: 0;
max-width: 100%;
font-size: 17px;
line-height: 29px;
letter-spacing: normal;
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.selected-memory-source {
margin: 32px 0 22px;
+2 -2
View File
@@ -1,13 +1,13 @@
# MemHub 安装与发行
MemHub `v1.0.5`(build 14)的发行目标如下:
MemHub `v1.0.5`(build 35)的发行目标如下:
| 平台 | 发行物 | 构建与验收 |
| --- | --- | --- |
| Linux x64 | Runtime tar.gz | GitHub Actions 原生 x64 runner |
| Linux arm64 | Runtime tar.gz | GitHub Actions 原生 arm64 runner |
| macOS arm64 | DMG 与 Runtime tar.gz | Developer ID 签名、公证和原生 arm64 验收 |
| macOS x64 | DMG 与 Runtime tar.gz | Developer ID 签名、公证和原生 Intel 验收 |
| macOS x64 | 无 | 本版本不提供 Intel 构建 |
| Windows | 无 | 当前不支持 |
正式支持以对应 GitHub Release 的 `release-manifest.json` 为准。Manifest 记录版本、
@@ -0,0 +1,66 @@
# TODO: recall imported conversations before a model is configured
Status: open. Found 2026-10-28 by the Linux lifecycle run on build 35.
## Problem
A new user who has imported conversations but not configured a generation
model gets nothing from `MemHub_recall` or `memhub recall`.
`recall_with_fallback` (`apps/core/src/one_memory/recall_fallback.py`) is
meant to cover this case: without a model it searches the SDK raw layer
(`l1_raw`), the imported conversations themselves. But under SDK authority
nothing writes that layer until a model is active:
- File import and live capture only stage conversations into
`memory_write_jobs` (assembling, then pending).
- The durable write drain claims a job only when
`_sedimentation_claim_enabled` holds, which requires an active model
profile (`api.py`).
- The SDK writes `l1_raw` only inside `client.add`, which the drain calls.
So until a model is configured, the raw layer is empty and the fallback has
nothing to search. Before the SDK cutover, imported Evidence lived in
MemHub's own database and was searchable without a model.
The Linux lifecycle verification used to assert "import, then recall finds
it" on a machine with no model. It now asserts that the import reached the
write queue instead (`scripts/platform/linux/verify-linux-lifecycle.mjs`,
`waitForQueued`). Restore the recall assertion once this is fixed.
## Direction
Write imported conversations to the SDK raw layer without a generation
model, and leave extraction for when a model is configured.
- The raw layer needs embeddings only. The bundled local ONNX model
(`builtin_retrieval_snapshot`) can produce them, so no network model is
needed.
- SDK: a public raw-only write, for example `add(..., infer=False)` or
`add_raw(messages, ...)`, that stores `l1_raw` with source, session,
workspace and `memory_at`, and returns its raw ids. It must be idempotent
by `request_id`, like `add`.
- MemHub: split the claim gate. Without a model, the drain may claim a
pending job for a raw-only write. Once a model is active, the same job
still needs extraction, and that write must not store `l1_raw` again.
Either the job records that its raw part is done, or extraction runs
through `add_extracted` / a mode that skips the raw store.
- The source sync switch and grant still apply, exactly as for extraction.
## Open questions
- Should raw-only ingestion run at all when the user has turned
sedimentation off, or does that switch mean "store nothing"?
- Storage cost: raw ingestion of a full history import without a model
embeds every turn up front. Check the time and disk cost on a large
history.
- When the user configures a model later, extract from the stored raw rows,
or re-read the jobs' frozen inputs? The second keeps one write path.
## Done when
- On a fresh install with no model, `memhub recall "<phrase from an
imported conversation>"` returns that conversation with `mode: "raw"`.
- Configuring a model afterwards extracts memories from the same
conversations without duplicating their raw rows.
- `verify-linux-lifecycle.mjs` asserts recall again.
@@ -7,7 +7,10 @@ export const applicationsChineseMessages = {
"apps.back": "返回首页",
"apps.overview": "记忆概览",
"apps.memories": "已沉淀记忆",
"apps.readingCount": "正在读取已沉淀记忆数量…",
"apps.readingCount": "正在核对本机记忆…",
"apps.startingRetry": "后台正在启动,稍后自动更新",
"apps.countWillUpdate": "记忆数量稍后自动更新",
"apps.showingPrevious": "正在显示上次数据,稍后自动更新",
"apps.unreadable": "暂无法读取",
"apps.unit": "条",
"apps.scope": "按应用授权范围使用",
@@ -16,9 +19,12 @@ export const applicationsChineseMessages = {
"apps.paused": "已暂停",
"apps.stoppedNew": "已停止生成新记忆",
"apps.checkingProcessing": "正在核对整理状态",
"apps.checkingStatus": "正在核对",
"apps.unchecked": "待核对",
"apps.organizingNew": "新的对话正在整理",
"apps.recentUpdate": "最近更新",
"apps.checkingRecent": "正在核对",
"apps.recentWillUpdate": "最近更新会自动显示",
"apps.notGenerated": "尚未生成",
"apps.saved": "记忆已保存",
"apps.separate": "历史与记忆分别处理",
@@ -26,14 +32,14 @@ export const applicationsChineseMessages = {
"apps.activityUnavailable": "动态暂不可用",
"apps.firstBatch": "首批沉淀",
"apps.callsUnavailable": "暂时无法读取调用记录",
"apps.memoryUnavailable": "暂时无法读取记忆状态",
"apps.memoryUnavailable": "记忆概览还没准备好",
"apps.importing": "对话持续导入中,即将沉淀你的记忆",
"apps.firstRunning": "正在沉淀第一批记忆",
"apps.firstQueued": "准备沉淀第一批记忆",
"apps.readingActivity": "正在读取动态",
"apps.readingActivity": "正在准备记忆概览",
"apps.emptyMemories": "还没有已生成的记忆",
"apps.callReadFailure": "已有记忆仍可查看;重试不会发起新的调用。",
"apps.memoryReadFailure": "读取失败不会当作没有记忆。",
"apps.memoryReadFailure": "后台恢复后会自动更新,也可以重新读取。",
"apps.savingConversations": "对话正在后台持续保存。",
"apps.waitingMemories": "历史已保存,记忆生成后会显示在这里。",
"apps.reread": "重新读取",
@@ -121,7 +127,12 @@ export const applicationsSpanishMessages: Record<
"apps.back": "Volver al inicio",
"apps.overview": "Resumen de memorias",
"apps.memories": "Memorias guardadas",
"apps.readingCount": "Leyendo el total de memorias…",
"apps.readingCount": "Comprobando las memorias locales…",
"apps.startingRetry":
"El servicio se está iniciando; se actualizará automáticamente",
"apps.countWillUpdate": "El total de memorias se actualizará automáticamente",
"apps.showingPrevious":
"Se muestran los datos anteriores; se actualizarán automáticamente",
"apps.unreadable": "No disponible",
"apps.unit": "",
"apps.scope": "Según los permisos de cada aplicación",
@@ -130,9 +141,12 @@ export const applicationsSpanishMessages: Record<
"apps.paused": "En pausa",
"apps.stoppedNew": "Se dejó de generar memorias nuevas",
"apps.checkingProcessing": "Comprobando el estado del procesamiento",
"apps.checkingStatus": "Verificando",
"apps.unchecked": "Sin verificar",
"apps.organizingNew": "Organizando nuevas conversaciones",
"apps.recentUpdate": "Última actualización",
"apps.checkingRecent": "Verificando",
"apps.recentWillUpdate": "La última actualización aparecerá automáticamente",
"apps.notGenerated": "Sin generar",
"apps.saved": "Memoria guardada",
"apps.separate": "Historial y memorias se procesan por separado",
@@ -140,16 +154,16 @@ export const applicationsSpanishMessages: Record<
"apps.activityUnavailable": "Actividad no disponible",
"apps.firstBatch": "Primeras memorias",
"apps.callsUnavailable": "No se pueden leer las consultas",
"apps.memoryUnavailable": "No se puede leer el estado de las memorias",
"apps.memoryUnavailable": "El resumen de memorias aún no está listo",
"apps.importing": "Importando conversaciones para generar memorias",
"apps.firstRunning": "Generando las primeras memorias",
"apps.firstQueued": "Preparando las primeras memorias",
"apps.readingActivity": "Leyendo actividad",
"apps.readingActivity": "Preparando el resumen de memorias",
"apps.emptyMemories": "Aún no hay memorias generadas",
"apps.callReadFailure":
"Puedes ver las memorias guardadas. Reintentar no inicia otra consulta.",
"apps.memoryReadFailure":
"Un error de lectura no significa que no haya memorias.",
"Se actualizará cuando el servicio se recupere. También puedes volver a leerlo.",
"apps.savingConversations": "Las conversaciones se guardan en segundo plano.",
"apps.waitingMemories":
"El historial está guardado. Las memorias aparecerán cuando se generen.",
+13 -4
View File
@@ -1305,7 +1305,12 @@ const baseEnglishMessages = {
"apps.back": "Back to home",
"apps.overview": "Memory overview",
"apps.memories": "Saved memories",
"apps.readingCount": "Loading memory count…",
"apps.readingCount": "Checking local memories…",
"apps.startingRetry":
"The background service is starting; this will update automatically",
"apps.countWillUpdate": "The memory count will update automatically",
"apps.showingPrevious":
"Showing the previous data; this will update automatically",
"apps.unreadable": "Unavailable",
"apps.unit": "items",
"apps.scope": "Used within app permissions",
@@ -1314,9 +1319,12 @@ const baseEnglishMessages = {
"apps.paused": "Paused",
"apps.stoppedNew": "No new memories are being created",
"apps.checkingProcessing": "Checking processing status",
"apps.checkingStatus": "Checking",
"apps.unchecked": "Needs review",
"apps.organizingNew": "Processing new conversations",
"apps.recentUpdate": "Recent update",
"apps.checkingRecent": "Checking",
"apps.recentWillUpdate": "The latest update will appear automatically",
"apps.notGenerated": "Not created yet",
"apps.saved": "Memory saved",
"apps.separate": "History and memories are processed separately",
@@ -1324,16 +1332,16 @@ const baseEnglishMessages = {
"apps.activityUnavailable": "Activity unavailable",
"apps.firstBatch": "First memories",
"apps.callsUnavailable": "Could not read call history",
"apps.memoryUnavailable": "Could not read memory status",
"apps.memoryUnavailable": "The memory overview is not ready yet",
"apps.importing": "Importing conversations before creating memories",
"apps.firstRunning": "Creating the first memories",
"apps.firstQueued": "Preparing the first memories",
"apps.readingActivity": "Loading activity",
"apps.readingActivity": "Preparing memory overview",
"apps.emptyMemories": "No memories created yet",
"apps.callReadFailure":
"You can still view saved memories. Retrying will not make a new call.",
"apps.memoryReadFailure":
"A failed read does not mean there are no memories.",
"This will update when the service recovers. You can also retry now.",
"apps.savingConversations":
"Conversations are still being saved in the background.",
"apps.waitingMemories": "History is saved. New memories will appear here.",
@@ -1613,6 +1621,7 @@ const baseEnglishMessages = {
"Newly imported original text, not yet memories",
"memory:来源归属": "Source connections",
"memory:可在沉淀记忆后查看视图": "Create memories to view the graph",
"memory:正在读取记忆地图…": "Loading memory map…",
"memory:查看选中来源原文": "View selected source text",
"memory:查看选中记忆原文": "View selected memory text",
"memory:查看原文": "View original conversation",
@@ -187,6 +187,7 @@ export const memoryChineseMessages = {
"memory:刚导入的原话,还不是沉淀后的记忆": "刚导入的原话,还不是沉淀后的记忆",
"memory:来源归属": "来源归属",
"memory:可在沉淀记忆后查看视图": "可在沉淀记忆后查看视图",
"memory:正在读取记忆地图…": "正在读取记忆地图…",
"memory:查看选中来源原文": "查看选中来源原文",
"memory:查看选中记忆原文": "查看选中记忆原文",
"memory:查看原文": "查看原文",
@@ -589,6 +590,7 @@ export const memorySpanishMessages: Record<
"memory:来源归属": "Relación con el origen",
"memory:可在沉淀记忆后查看视图":
"La vista estará disponible cuando se generen memorias",
"memory:正在读取记忆地图…": "Leyendo el mapa de memorias…",
"memory:查看选中来源原文": "Ver texto del origen seleccionado",
"memory:查看选中记忆原文": "Ver texto de la memoria seleccionada",
"memory:查看原文": "Ver texto original",
+2 -1
View File
@@ -25,7 +25,8 @@ test("installation docs disclose publication and lifecycle boundaries", async ()
assert.match(installation, /Linux x64.*GitHub Actions/s);
assert.match(installation, /Linux arm64.*GitHub Actions/s);
assert.match(installation, /macOS arm64.*Developer ID/s);
assert.match(installation, /macOS x64.*Developer ID/s);
// v1.0.5 ships no Intel build; the table has to say so rather than omit it.
assert.match(installation, /\| macOS x64 \| 无 \|/);
assert.match(installation, /release-manifest\.json/);
assert.match(installation, /fake Release/);
assert.match(installation, /--source/);
@@ -66,7 +66,6 @@ describe("Linux runtime scripts", () => {
'"stop"',
'"init"',
'"scan"',
'"recall"',
'"uninstall"',
]) {
assert.match(script, new RegExp(command));
@@ -88,9 +87,11 @@ describe("Linux runtime scripts", () => {
script,
/Exit 1 is a valid transient receipt[\s\S]{0,300}\[0, 1, 3\]/,
);
assert.match(script, /append_recalled/);
assert.match(script, /initial_queued/);
assert.match(script, /append_queued/);
assert.match(script, /FROM memory_write_jobs/);
assert.match(script, /idempotent_upgrade_instance_id_changed/);
assert.match(script, /post_upgrade_recalled/);
assert.match(script, /post_upgrade_queued/);
assert.match(script, /uninstall_preserved_data/);
assert.match(script, /is-enabled/);
assert.match(script, /is-active/);
@@ -10,6 +10,7 @@ import {
} from "node:fs";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
function fail(message, receipt) {
if (receipt) process.stdout.write(`${JSON.stringify(receipt, null, 2)}\n`);
@@ -146,18 +147,35 @@ function doctorCheck(document, id) {
return check;
}
async function waitForRecall(prefix, phrase) {
/**
* Whether an imported phrase reached Core's durable write queue.
*
* Recall cannot prove an import here: this runner has no generation model,
* and with SDK authority nothing reaches the SDK (not even its raw layer)
* until a model is active. What the lifecycle can prove is that the import
* was read and queued for the SDK, durably, in the Runtime's own database.
*/
async function waitForQueued(phrase) {
const deadline = Date.now() + 90_000;
let attempt = 0;
const database = path.join(dataDir, "one-memory.sqlite3");
while (Date.now() < deadline) {
attempt += 1;
const result = run(
`${prefix}-${attempt}`,
["recall", phrase, "--json"],
[0, 1, 3],
15_000,
);
if (result.stdout.includes(phrase)) return true;
if (existsSync(database)) {
let connection;
try {
connection = new DatabaseSync(database, { readOnly: true });
const row = connection
.prepare(
"SELECT COUNT(*) AS count FROM memory_write_jobs " +
"WHERE instr(payload_json, ?) > 0",
)
.get(phrase);
if (Number(row?.count) > 0) return true;
} catch {
// The queue table appears once Core has migrated its schema.
} finally {
connection?.close();
}
}
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
return false;
@@ -320,12 +338,11 @@ try {
"--import",
"--json",
]);
observations.initial_recalled = await waitForRecall(
"recall-initial",
observations.initial_queued = await waitForQueued(
"Linux lifecycle initial phrase",
);
if (!observations.initial_recalled)
throw new Error("initial import was not recalled");
if (!observations.initial_queued)
throw new Error("initial import was not queued for the SDK");
status.import = "passed";
writeFileSync(
@@ -338,12 +355,11 @@ try {
[0],
30_000,
);
observations.append_recalled = await waitForRecall(
"recall-appended",
observations.append_queued = await waitForQueued(
"Linux lifecycle appended phrase",
);
if (!observations.append_recalled)
throw new Error("appended turn was not recalled");
if (!observations.append_queued)
throw new Error("appended turn was not queued for the SDK");
status.append_sync = "passed";
const beforeUpgrade = await waitForRunningStatus("status-before-upgrade");
@@ -378,12 +394,11 @@ try {
if (!observations.idempotent_upgrade_instance_id_changed) {
throw new Error("idempotent upgrade did not restart the Runtime");
}
observations.post_upgrade_recalled = await waitForRecall(
"recall-after-upgrade",
observations.post_upgrade_queued = await waitForQueued(
"Linux lifecycle appended phrase",
);
if (!observations.post_upgrade_recalled) {
throw new Error("idempotent upgrade lost previously recalled data");
if (!observations.post_upgrade_queued) {
throw new Error("idempotent upgrade lost previously queued data");
}
status.upgrade_idempotent = "passed";