Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2823bd743d | ||
|
|
837c084be2 | ||
|
|
bf52724461 | ||
|
|
964d8610f3 | ||
|
|
e1933d3307 | ||
|
|
5fdaf6c280 | ||
|
|
58f2fa884a | ||
|
|
c0910621c1 | ||
|
|
dd13c551c4 | ||
|
|
e7d82ee5e8 | ||
|
|
63e009bf6c | ||
|
|
d4cfd1bb74 | ||
|
|
2822cbf368 | ||
|
|
8d23347e1a | ||
|
|
8ae0d1889e | ||
|
|
2479de22eb | ||
|
|
265a22d612 | ||
|
|
93d0c64490 | ||
|
|
1589ae31bc | ||
|
|
38ad2292d1 | ||
|
|
413b9033f9 | ||
|
|
0953c5af35 | ||
|
|
470ea4459a | ||
|
|
b58e95fdae | ||
|
|
42b577f6f8 | ||
|
|
2554fb3b96 | ||
|
|
e32dc987c2 | ||
|
|
598e83525e | ||
|
|
01ace26d08 | ||
|
|
ba2f647a30 | ||
|
|
82d9514d62 | ||
|
|
369351719d | ||
|
|
1429ba2d19 | ||
|
|
4e7d752980 | ||
|
|
fb13a6fda2 | ||
|
|
6ff8b3a3b4 | ||
|
|
17441d368c | ||
|
|
89d29c9c41 | ||
|
|
5271bddce3 | ||
|
|
eac795b2d2 | ||
|
|
d938f1d5b2 | ||
|
|
8cee64c813 | ||
|
|
1ffcc9eb57 | ||
|
|
23b74f91bc | ||
|
|
b36452d110 | ||
|
|
a8b92d21e1 | ||
|
|
e58a7c0d14 | ||
|
|
1663c9424e | ||
|
|
fba9d8cf26 | ||
|
|
f754f8bc0f | ||
|
|
2155c4c773 | ||
|
|
18e2fca34d | ||
|
|
88feb0adca | ||
|
|
d2e9cdaf06 | ||
|
|
8277aaee51 | ||
|
|
65550621e0 | ||
|
|
1a67427bf3 | ||
|
|
58f1477680 | ||
|
|
76c459e39a | ||
|
|
b864e0bda6 | ||
|
|
6c235ef093 | ||
|
|
2483412475 | ||
|
|
f9ee983790 | ||
|
|
a218a92d4e | ||
|
|
6845adf083 |
No files matched your search
+94
-57
@@ -4,34 +4,50 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- MemHub multi-user hardening (follow-up to #250):
|
||||
- Read responses name the user they were read for: `user_id` (the one
|
||||
asked for, `local` by default) is now on `GET /api/v2/memory-library`,
|
||||
`/memory-library/layers`, `/user-profile`, `/home-summary`,
|
||||
`/memories/graph`, `/raw`, and `POST /api/v2/context/search`,
|
||||
`/api/v1/recall`, `/api/v2/memories/ask`. `ask/stream` carries it on its
|
||||
first (`recall`) event. The Host Bridge context validator accepts the new
|
||||
field and still accepts a Core without it.
|
||||
- `DELETE /api/v2/memories/{id}` answers 404 `memory_not_found` for another
|
||||
user's memory and for an id that does not exist (the same response, so
|
||||
existence is still not revealed), and also when the SDK finds nothing to
|
||||
delete; 204 only when the caller's own memory was deleted. The App treats
|
||||
that 404 as already gone (a double-click delete no longer shows an
|
||||
error); `memhub memories delete` prints "未找到记忆 <id>(可能已删除)",
|
||||
exits 0, and its JSON is `state: "committed"` with `already_gone: true`.
|
||||
- Hermes never reads a `profiles/` directory at any depth (scan, detect and
|
||||
live sync), so a root granted above `~/.hermes` cannot pick up another
|
||||
user's profile. Connecting refuses a root that is the `profiles`
|
||||
container itself (422 `hermes_root_is_profiles_container`) and a root
|
||||
equal to, containing or inside another user's active Hermes grant (409
|
||||
`hermes_root_overlaps_other_user`, symlinks resolved); `local` on
|
||||
`~/.hermes` with another user on `~/.hermes/profiles/<name>` stays
|
||||
allowed. The CLI explains both codes.
|
||||
- `memhub sources connect --user-id` with an invalid id now says the user
|
||||
id need not equal the Hermes profile name (e.g. `--user-id wife-work
|
||||
--root ~/.hermes/profiles/wife:work`).
|
||||
- MemHub App now exposes the complete memory-mode lifecycle. New installs
|
||||
explicitly choose lite or pro during onboarding; pro completes only after a
|
||||
generation model is active, while ultra remains an advanced Settings choice.
|
||||
Settings shows mode effects, graph backfill, the default 03:00 evolve
|
||||
schedule and manual evolve. The memory page shows original conversations in
|
||||
lite and advanced memories in pro/ultra. Existing installs remain pro unless
|
||||
the user changes them, and embedding maintenance blocks mode/evolve changes.
|
||||
- MemHub pins `hy-memory==2.0.14`. Lite search is plain RAG over raw: the
|
||||
App's lite search, CLI context search, `/api/v1/recall` and the Host Bridge
|
||||
(`MemHub_recall`, context search) return every vector hit above the floor,
|
||||
ranked by score, over active and shadow raws, so history pro already
|
||||
processed is still found after switching to lite. The turn view no longer
|
||||
drops a hit whose turns do not literally contain the query; it shows the
|
||||
hit's closest turn instead. Pro and ultra raw search stays active-only.
|
||||
Fixes every raw search failing in packaged builds since #316, which passed
|
||||
`statuses` to an SDK (2.0.13) that did not accept it. The lite raw-turn
|
||||
page also stops re-listing the whole raw layer on each poll when a live
|
||||
session only adds new raws.
|
||||
- MemHub Core no longer stalls writes or freezes the App for tens of seconds
|
||||
on a machine with a long live-sync history. Finished live-sync import jobs
|
||||
are pruned to the newest 20 per source (at startup, in bounded batches, and
|
||||
every 10 minutes); history imports and every unfinished job are kept. Job
|
||||
lookups are filtered in SQL with new indexes (schema migration 63) instead
|
||||
of reading the whole table. The durable write queue runs its claims,
|
||||
settles and lease renewals on its own threads, so import work can no longer
|
||||
starve a lease. Import abandon, resume and enqueue, and the file watcher's
|
||||
path attribution, no longer touch the database or the disk on the event
|
||||
loop, and the WAL cadence checkpoint runs on its own thread instead of on
|
||||
the writer's. The write drain now restarts itself after an unexpected exit,
|
||||
reports the failure with its stack, and shows `write_drain` /
|
||||
`write_drain_error` in memory-processing and `memhub status`.
|
||||
|
||||
- Hy Memory: a model failure during a write now reports failure and stores
|
||||
## Hy Memory 2.0.14 — 2026-11-04
|
||||
|
||||
- `search()` / `async_search()` take an optional `statuses` for explicit
|
||||
L1_RAW search (`layers=["l1_raw"]`, or any search in lite mode), e.g.
|
||||
`statuses=["active", "shadow"]`, so raw turns a pro extraction already
|
||||
shadowed can still be searched. Default stays active-only. Raw hits now
|
||||
report their real `status`. Passing `statuses` to a non-raw search or to
|
||||
`scene="all"` raises `ValueError` instead of being ignored.
|
||||
|
||||
## Hy Memory 2.0.13 — 2026-11-02
|
||||
|
||||
- A model failure during a write now reports failure and stores
|
||||
nothing. **Behaviour change for direct SDK users:** writes that used to
|
||||
return `success: true` in a degraded state now return `success: false`;
|
||||
retry them with a new `request_id` once the model is back.
|
||||
@@ -78,30 +94,6 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
superseding anything. Fact reconcile still makes no model call when there
|
||||
are no candidates.
|
||||
|
||||
- MemHub's DeepSeek Harness app page can grant recall again (#230). The
|
||||
"一键插入应用" switch used to be greyed out with `unsupported_host`, and a
|
||||
profile installed from the CLI stayed `install-only` with no way to approve
|
||||
it. The one switch now covers every detected profile
|
||||
(`deepseek-harness:<profile>`): on installs each profile's MCP patch and
|
||||
grants recall, off removes the patch and revokes the grant. The page lists
|
||||
each profile with its state, and names any profile that failed while
|
||||
keeping the others. While the switch is on, a profile created later is
|
||||
installed and granted automatically, without restarting MemHub; the Runtime
|
||||
also no longer fails every host read when it meets a profile created after
|
||||
it started. The harness docs no longer describe an unmounted sources page.
|
||||
|
||||
- MemHub's `deepseek-harness` source imports session files whose names carry the
|
||||
format generation: `session.v3.jsonl.zstd` and `session.v4.jsonl.zstd` are now
|
||||
discovered alongside the original `session.jsonl[.zstd]`. Discovery accepts
|
||||
any `session[.vN].jsonl[.zstd]` name and the header's `version` decides
|
||||
whether the session is parsed (v0/v3/v4) or refused as `unsupported_schema`.
|
||||
A refused file is reported and keeps no cursor, so the release that learns
|
||||
its format reads it from the top.
|
||||
v4 tool results, which are written as plain `text` blocks rather than
|
||||
`tool-result` blocks, are read through the same clipping and credential
|
||||
scrubbing path. Previously every versioned session was invisible: the import
|
||||
job completed with `discovered 0` while the files sat on disk.
|
||||
|
||||
## Hy Memory 2.0.12 — 2026-11-02
|
||||
|
||||
- Intention dedup is now configurable per client
|
||||
@@ -124,14 +116,17 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## MemHub 1.0.6 — 2026-11-02
|
||||
|
||||
Build 37, on `hy-memory==2.0.11`. Linux x64/arm64 Runtime and macOS arm64
|
||||
Build 41, on `hy-memory==2.0.14`. Linux x64/arm64 Runtime and macOS arm64
|
||||
App and Runtime.
|
||||
|
||||
- Interfaces in 11 languages: English, Simplified and Traditional Chinese,
|
||||
Japanese, Spanish, German, Portuguese, French, Korean, Russian and
|
||||
Indonesian. Choosing any language other than Simplified Chinese also sets
|
||||
the SDK prompt language; Simplified Chinese keeps the automatic zh/en
|
||||
detection.
|
||||
Indonesian. Every interface language, Simplified Chinese included, sets the
|
||||
SDK prompt language (#270). **User-visible:** zh-Hans users now get Chinese
|
||||
prompts and Chinese memory text for every conversation, English ones
|
||||
included; the App sends the language it displays, also when it follows the
|
||||
system. Only an unset language (CLI/API installs, `memhub settings language
|
||||
system`) keeps the SDK's zh/en detection.
|
||||
- Memory modes: lite (no LLM, keeps raw conversations), pro (LLM extraction)
|
||||
and ultra (pro plus the knowledge graph and a nightly evolve run). Set with
|
||||
`GET`/`PUT /api/v1/settings/memory-mode` or `memhub settings mode`. Raw
|
||||
@@ -144,10 +139,52 @@ App and Runtime.
|
||||
embedding service is unavailable, search returns `embedding_unavailable`.
|
||||
- Multi-user Runtime: memory APIs and the CLI take `user_id` (default
|
||||
`local`); records are isolated per user; Hermes profiles can be connected as
|
||||
separate users. The App keeps using `local`.
|
||||
separate users. The App keeps using `local`. Hardening (#280): read
|
||||
responses echo `user_id`; deleting another user's memory, or one that does
|
||||
not exist, is 404 `memory_not_found` (the App and `memhub memories delete`
|
||||
treat it as already gone); Hermes never reads a `profiles/` directory, and
|
||||
a root that is the `profiles` container or overlaps another user's grant is
|
||||
refused (`hermes_root_is_profiles_container`,
|
||||
`hermes_root_overlaps_other_user`).
|
||||
- Configurable SDK features through the CLI and API (`memhub settings
|
||||
features`): graph rerank, episode recall, session episodes, episode
|
||||
summaries, intention and few-shot. Defaults are unchanged.
|
||||
summaries, intention and few-shot, and the intention dedup switch with its
|
||||
threshold (`intention_dedup`, `intention_dedup_threshold`, #277). Defaults
|
||||
are unchanged.
|
||||
- Memory writes retry themselves (#278), each time under a new SDK request
|
||||
id after checking whether the previous one landed; interrupted writes
|
||||
re-run automatically. A model provider failure (401/403, 404, 408, 429,
|
||||
5xx, timeout, unreachable) keeps the job pending in place without spending
|
||||
a retry, and a per-profile circuit breaker paces the queue (5 s doubling to
|
||||
180 s, one probe job at a time). Any other failure is retried after 30, 60
|
||||
and 120 s, then `failed`. With `hy-memory==2.0.13` a model failure is no
|
||||
longer a silent success, and a failed job's `error_code` names the SDK's
|
||||
cause (`hy_sdk_reconcile_empty_response`, `hy_sdk_llm_error`, ...) rather
|
||||
than `hy_sdk_write_failed`. **User-visible:** the manual 重新整理 button,
|
||||
the "整理结果不确定" display and
|
||||
`POST /api/v1/memory-processing/retry-unknown` are gone; a banner
|
||||
("模型配置有问题……记忆暂停处理,已有 N 条在排队") with a button to the model
|
||||
settings shows while a model problem pauses processing, and clears on
|
||||
recovery.
|
||||
- Queue, WAL and recall (#278): the WAL stays bounded (64 MiB journal limit,
|
||||
idle `TRUNCATE` checkpoints); a disabled or revoked source's jobs are
|
||||
`suspended` and resume when it is turned back on; SDK writes and recall
|
||||
reads use separate thread pools, and recall has a total budget (504
|
||||
`recall_timeout`, `broker_timeout` in clients) instead of starving behind
|
||||
stuck writes.
|
||||
- One Runtime per data dir (#273). App quit asks the service manager at quit
|
||||
time: an enabled background service is never stopped, a disabled one has
|
||||
every Runtime of the data dir stopped (only after an authenticated identity
|
||||
check), and an unknown state stops nothing. At start the App adopts a
|
||||
Runtime of the same build, replaces an older one, and silently adopts a
|
||||
newer one. The tray quit label follows the current state.
|
||||
- DeepSeek Harness: the app page's one switch installs and grants recall for
|
||||
every detected profile, and keeps new profiles granted without a restart
|
||||
(#230); versioned session files (`session.v3.jsonl.zstd`,
|
||||
`session.v4.jsonl.zstd`) are imported, and v4 tool output goes through the
|
||||
same clipping and scrubbing (#175).
|
||||
- The packaged Runtime accepts only a full 40-character commit, lowercased; a
|
||||
malformed `ONE_MEMORY_COMMIT` fails the build (#235, #236).
|
||||
- Search and recall pass through the SDK's search parameters with server-side
|
||||
score floors; the memory library filters by layer; `/api/v2/raw` returns
|
||||
data again; v2 source and import routes; profile version history.
|
||||
|
||||
@@ -33,7 +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-11]**: 📦 **Released [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)** with interfaces in 11 languages, memory modes (lite / pro / ultra with nightly evolve), safe embedding-model switching, multi-user support for the CLI and API, and more reliable upgrades, on **`hy-memory==2.0.11`**. See the release notes for details.
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **Released [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)** with interfaces in 11 languages, memory modes (lite / pro / ultra with nightly evolve), safe embedding-model switching, multi-user support for the CLI and API, memory writes that retry themselves and pause while the model provider is down, and more reliable upgrades, on **`hy-memory==2.0.14`**. See the release notes for details.
|
||||
* *<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)**!
|
||||
|
||||
+1
-1
@@ -33,7 +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-11]**: 📦 **正式发布 [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)**:界面支持 11 种语言,新增记忆模式(lite / pro / ultra,ultra 每晚自动整理),可安全切换嵌入模型,CLI 和 API 支持多用户,升级更可靠;底层使用 **`hy-memory==2.0.11`**。详见发布说明。
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **正式发布 [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)**:界面支持 11 种语言,新增记忆模式(lite / pro / ultra,ultra 每晚自动整理),可安全切换嵌入模型,CLI 和 API 支持多用户,记忆写入自动重试、模型服务异常时暂停排队,升级更可靠;底层使用 **`hy-memory==2.0.14`**。详见发布说明。
|
||||
* *<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)**!
|
||||
|
||||
+24
-5
@@ -1864,6 +1864,7 @@ class HyMemoryClient:
|
||||
branch: Optional[str] = None,
|
||||
request_id: Optional[str] = None,
|
||||
layers: Optional[List[Any]] = None,
|
||||
statuses: Optional[List[Any]] = None,
|
||||
embed_override: Optional[Dict[str, Any]] = None,
|
||||
query_embedding: Optional[List[float]] = None,
|
||||
rerank: bool = True,
|
||||
@@ -1956,6 +1957,8 @@ class HyMemoryClient:
|
||||
layers: 可选。pro/ultra 下只召回指定层级(字符串或
|
||||
MemoryLayer),在召回阶段生效。lite 下忽略该参数并
|
||||
强制只召回 L1_RAW。
|
||||
statuses: 仅用于 L1_RAW 定向搜索,可显式包含 active / shadow。
|
||||
非 raw 搜索或 scene="all" 传入时拒绝,避免静默忽略。
|
||||
rerank: 默认 True(原行为)。False 时本次调用跳过全部重排阶段:
|
||||
chat 路的本地 cross-encoder 重排(recall.local_rerank_enabled)
|
||||
与记忆关系图重排(graph_rerank / episode 扩展),working/
|
||||
@@ -1999,7 +2002,7 @@ class HyMemoryClient:
|
||||
intention_limit=intention_limit, created_after=created_after,
|
||||
reader=reader,
|
||||
workspace_id=workspace_id, branch=branch,
|
||||
request_id=request_id, layers=layers,
|
||||
request_id=request_id, layers=layers, statuses=statuses,
|
||||
embed_override=embed_override,
|
||||
query_embedding=query_embedding,
|
||||
rerank=rerank,
|
||||
@@ -3387,12 +3390,14 @@ class HyMemoryClient:
|
||||
request_id: str,
|
||||
t0: float,
|
||||
query_embedding: Optional[List[float]],
|
||||
statuses: Optional[List[Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Implement `search(..., layers=['l1_raw'])` without a second API."""
|
||||
from .models.memory import MemoryLayer, MemoryStatus
|
||||
|
||||
embedding = query_embedding or await self._embed_service.embed(query)
|
||||
fetch = min(max(limit * (10 if session_ids else 2), limit), 10_000)
|
||||
status_filter = self._normalize_statuses(statuses) or [MemoryStatus.ACTIVE]
|
||||
hits = await self._vector_store.search(
|
||||
query_embedding=embedding,
|
||||
user_ids=user_ids,
|
||||
@@ -3402,7 +3407,7 @@ class HyMemoryClient:
|
||||
layers=[MemoryLayer.L1_RAW],
|
||||
limit=fetch,
|
||||
score_threshold=min_score,
|
||||
status_filter=[MemoryStatus.ACTIVE],
|
||||
status_filter=status_filter,
|
||||
only_latest=False,
|
||||
created_after=created_after,
|
||||
)
|
||||
@@ -3420,7 +3425,7 @@ class HyMemoryClient:
|
||||
"memory_id": row.pop("raw_id"),
|
||||
**row,
|
||||
"layer": MemoryLayer.L1_RAW.value,
|
||||
"status": MemoryStatus.ACTIVE.value,
|
||||
"status": node.status.value,
|
||||
"score": round(float(hit.get("score", 0.0)), 4),
|
||||
}
|
||||
)
|
||||
@@ -3864,6 +3869,7 @@ class HyMemoryClient:
|
||||
branch: Optional[str] = None,
|
||||
request_id: Optional[str] = None,
|
||||
layers: Optional[List[Any]] = None,
|
||||
statuses: Optional[List[Any]] = None,
|
||||
embed_override: Optional[Dict[str, Any]] = None,
|
||||
query_embedding: Optional[List[float]] = None,
|
||||
rerank: bool = True,
|
||||
@@ -3893,7 +3899,7 @@ class HyMemoryClient:
|
||||
schema_min_score=schema_min_score, schema_limit=schema_limit,
|
||||
intention_limit=intention_limit, created_after=created_after,
|
||||
reader=reader, workspace_id=workspace_id, branch=branch,
|
||||
request_id=request_id, layers=layers,
|
||||
request_id=request_id, layers=layers, statuses=statuses,
|
||||
query_embedding=query_embedding,
|
||||
rerank=rerank,
|
||||
)
|
||||
@@ -3908,7 +3914,7 @@ class HyMemoryClient:
|
||||
schema_min_score=schema_min_score, schema_limit=schema_limit,
|
||||
intention_limit=intention_limit, created_after=created_after,
|
||||
reader=reader, workspace_id=workspace_id, branch=branch,
|
||||
request_id=request_id, layers=layers,
|
||||
request_id=request_id, layers=layers, statuses=statuses,
|
||||
query_embedding=query_embedding,
|
||||
rerank=rerank,
|
||||
)
|
||||
@@ -3938,6 +3944,7 @@ class HyMemoryClient:
|
||||
branch: Optional[str] = None,
|
||||
request_id: Optional[str] = None,
|
||||
layers: Optional[List[Any]] = None,
|
||||
statuses: Optional[List[Any]] = None,
|
||||
query_embedding: Optional[List[float]] = None,
|
||||
rerank: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
@@ -4023,6 +4030,17 @@ class HyMemoryClient:
|
||||
"Use single agent_id with multiple session_ids, or multiple agent_ids without session_ids."
|
||||
)
|
||||
|
||||
if statuses is not None:
|
||||
from .models.memory import MemoryLayer
|
||||
|
||||
requested_layers = self._normalize_layers(layers)
|
||||
if scene_all or (
|
||||
not lite_mode and requested_layers != [MemoryLayer.L1_RAW]
|
||||
):
|
||||
raise ValueError(
|
||||
"statuses is supported only for explicit L1_RAW search"
|
||||
)
|
||||
|
||||
if scene_all:
|
||||
return await self._search_all_scenes(
|
||||
query, queries=queries, search_mode=search_mode,
|
||||
@@ -4358,6 +4376,7 @@ class HyMemoryClient:
|
||||
request_id=request_id,
|
||||
t0=t0,
|
||||
query_embedding=query_embedding,
|
||||
statuses=statuses,
|
||||
)
|
||||
|
||||
read_req = ReadRequest(
|
||||
|
||||
+3
-3
@@ -18,8 +18,8 @@ If you run into a problem, please open an Issue with the version, platform,repro
|
||||
|
||||
## 发行与平台合同
|
||||
|
||||
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.12`。MemHub 源码版本为 `1.0.6`
|
||||
(build 37),GitHub 仓库为
|
||||
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.14`。MemHub 源码版本为 `1.0.6`
|
||||
(build 41),GitHub 仓库为
|
||||
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
|
||||
`memhub-cli`。当前代码架构见
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
|
||||
@@ -118,7 +118,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
|
||||
|
||||
## Hy Memory
|
||||
|
||||
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.12`。SDK 配置、权限边界及 search
|
||||
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.14`。SDK 配置、权限边界及 search
|
||||
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
|
||||
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
|
||||
|
||||
|
||||
@@ -2352,7 +2352,7 @@
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-hy-memory",
|
||||
"name": "hy-memory",
|
||||
"versionInfo": "2.0.12",
|
||||
"versionInfo": "2.0.14",
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": false,
|
||||
"licenseConcluded": "Apache-2.0",
|
||||
|
||||
@@ -9,7 +9,7 @@ Per-package license texts are not reproduced here yet; the license
|
||||
column is filled in as part of release review, and the SBOM records
|
||||
`NOASSERTION` until then rather than guessing.
|
||||
|
||||
## hy-memory 2.0.12
|
||||
## hy-memory 2.0.14
|
||||
|
||||
License: Apache-2.0
|
||||
|
||||
|
||||
@@ -4383,6 +4383,10 @@ function renderWriteReceiptText(receipt: MemoryWriteReceipt): string {
|
||||
...(receipt.memory_ids && receipt.memory_ids.length > 0
|
||||
? [`memory_ids: ${receipt.memory_ids.join(",")}`]
|
||||
: []),
|
||||
...(receipt.error_code ? [`error_code: ${receipt.error_code}`] : []),
|
||||
...(receipt.next_retry_at
|
||||
? [`next_retry_at: ${receipt.next_retry_at}`]
|
||||
: []),
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -17,11 +17,14 @@ const READ_ONLY_PATHS = [
|
||||
"/api/v1/model-profiles",
|
||||
"/api/v1/source-grants",
|
||||
"/api/v1/sources",
|
||||
"/api/v2/home-projection",
|
||||
"/api/v2/home-summary",
|
||||
"/api/v2/raw",
|
||||
"/api/v2/memory-library",
|
||||
"/api/v2/user-profile",
|
||||
"/api/v2/user-profile/versions",
|
||||
"/api/v1/settings/memory-mode",
|
||||
"/api/v1/settings/memory-mode/plan",
|
||||
"/api/v1/settings/memory-features",
|
||||
"/api/v1/settings/ui-language",
|
||||
"/api/v2/evolve",
|
||||
@@ -268,6 +271,11 @@ export interface MemoryWriteReceipt {
|
||||
detail?: string;
|
||||
raw_ids?: string[];
|
||||
memory_ids?: string[];
|
||||
/** Stable code of the last failure while the write still waits; null when
|
||||
* none. Optional so older Runtimes stay readable. */
|
||||
error_code?: string | null;
|
||||
/** When Core will try this write again (ISO); null when not scheduled. */
|
||||
next_retry_at?: string | null;
|
||||
}
|
||||
|
||||
export interface OpenAIMessage {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
HomeProjectionV1,
|
||||
ImportIssue,
|
||||
ImportJob,
|
||||
LiveSyncHealth,
|
||||
@@ -26,6 +27,9 @@ export const MEMORY_STATUSES = [
|
||||
const ID_MAX = 128;
|
||||
const LABEL_MAX = 256;
|
||||
const LIST_MAX = 500;
|
||||
const USER_ID = /^[A-Za-z0-9._-]{1,64}$/;
|
||||
const ISO_TIME =
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
||||
const LIVE_SYNC_STATES = [
|
||||
"idle",
|
||||
"running",
|
||||
@@ -406,6 +410,153 @@ export function validateHomeSummary(value: unknown): SdkHomeSummary | null {
|
||||
return summary as unknown as SdkHomeSummary;
|
||||
}
|
||||
|
||||
/** `GET /api/v2/home-projection`: the mode-aware home content plane. */
|
||||
export function validateHomeProjection(
|
||||
value: unknown,
|
||||
): HomeProjectionV1 | null {
|
||||
const projection = exactRecord(value, [
|
||||
"projection_version",
|
||||
"user_id",
|
||||
"effective_mode",
|
||||
"capabilities",
|
||||
"visible_count",
|
||||
"display_state",
|
||||
"latest",
|
||||
"observed_at",
|
||||
]);
|
||||
if (!projection) return null;
|
||||
const mode = projection.effective_mode;
|
||||
const capabilities = exactRecord(projection.capabilities, [
|
||||
"content_plane",
|
||||
"generation_required",
|
||||
"graph_enabled",
|
||||
"evolve_available",
|
||||
]);
|
||||
if (
|
||||
projection.projection_version !== 1 ||
|
||||
typeof projection.user_id !== "string" ||
|
||||
!USER_ID.test(projection.user_id) ||
|
||||
!oneOf(mode, ["lite", "pro", "ultra"] as const) ||
|
||||
!capabilities ||
|
||||
capabilities.content_plane !== (mode === "lite" ? "raw" : "memory") ||
|
||||
capabilities.generation_required !== (mode !== "lite") ||
|
||||
capabilities.graph_enabled !== (mode === "ultra") ||
|
||||
capabilities.evolve_available !== (mode === "ultra") ||
|
||||
count(projection.visible_count) === null ||
|
||||
!oneOf(projection.display_state, [
|
||||
"empty",
|
||||
"excerpt",
|
||||
"no_user_excerpt",
|
||||
"no_displayable_memory",
|
||||
] as const) ||
|
||||
!isoTime(projection.observed_at) ||
|
||||
!validProjectionLatest(projection.latest, mode)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const visible = projection.visible_count as number;
|
||||
const latest = projection.latest as Record<string, unknown> | null;
|
||||
if (
|
||||
(visible === 0 &&
|
||||
(latest !== null || projection.display_state !== "empty")) ||
|
||||
(visible > 0 && projection.display_state === "empty") ||
|
||||
(latest === null &&
|
||||
["excerpt", "no_user_excerpt"].includes(
|
||||
projection.display_state as string,
|
||||
)) ||
|
||||
(latest?.type === "raw" &&
|
||||
projection.display_state !== latest.display_state) ||
|
||||
(latest?.type === "memory" && projection.display_state !== "excerpt")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return projection as unknown as HomeProjectionV1;
|
||||
}
|
||||
|
||||
function validProjectionLatest(
|
||||
value: unknown,
|
||||
mode: "lite" | "pro" | "ultra",
|
||||
): boolean {
|
||||
if (value === null) return true;
|
||||
const base = [
|
||||
"type",
|
||||
"time",
|
||||
"time_basis",
|
||||
"excerpt",
|
||||
"truncated",
|
||||
"source_id",
|
||||
];
|
||||
const candidate = record(value);
|
||||
if (!candidate) return false;
|
||||
const keys =
|
||||
candidate.type === "raw"
|
||||
? [...base, "raw_id", "session_id", "display_state"]
|
||||
: [...base, "memory_id", "scene", "kind", "variant"];
|
||||
const latest = exactRecord(value, keys);
|
||||
if (
|
||||
!latest ||
|
||||
!(latest.time === null || isoTime(latest.time)) ||
|
||||
![null, "conversation", "import", "write", "update"].includes(
|
||||
latest.time_basis as null | string,
|
||||
) ||
|
||||
text(latest.excerpt, 200) === null ||
|
||||
typeof latest.truncated !== "boolean" ||
|
||||
text(latest.source_id, ID_MAX) === null
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (latest.type === "raw") {
|
||||
return (
|
||||
mode === "lite" &&
|
||||
nonemptyText(latest.raw_id, ID_MAX) &&
|
||||
nonemptyText(latest.session_id, LABEL_MAX) &&
|
||||
oneOf(latest.display_state, ["excerpt", "no_user_excerpt"] as const) &&
|
||||
(latest.display_state !== "no_user_excerpt" ||
|
||||
(latest.excerpt === "" && latest.truncated === false))
|
||||
);
|
||||
}
|
||||
return (
|
||||
latest.type === "memory" &&
|
||||
mode !== "lite" &&
|
||||
nonemptyText(latest.memory_id, ID_MAX) &&
|
||||
oneOf(latest.scene, ["chat", "working", "coding"] as const) &&
|
||||
oneOf(latest.kind, ["chat", "experience"] as const) &&
|
||||
[null, "working", "coding"].includes(latest.variant as null | string) &&
|
||||
((latest.kind === "chat" &&
|
||||
latest.scene === "chat" &&
|
||||
latest.variant === null) ||
|
||||
(latest.kind === "experience" &&
|
||||
latest.scene === latest.variant &&
|
||||
oneOf(latest.scene, ["working", "coding"] as const)))
|
||||
);
|
||||
}
|
||||
|
||||
/** The bounded `/api/v2/raw?limit=1` envelope used only for its real total. */
|
||||
export function validateRawTotal(value: unknown): number | null {
|
||||
const page = exactRecord(value, [
|
||||
"user_id",
|
||||
"items",
|
||||
"total",
|
||||
"offset",
|
||||
"limit",
|
||||
"time_filtered",
|
||||
]);
|
||||
if (
|
||||
!page ||
|
||||
typeof page.user_id !== "string" ||
|
||||
!USER_ID.test(page.user_id) ||
|
||||
!Array.isArray(page.items) ||
|
||||
page.items.length > 1 ||
|
||||
count(page.total) === null ||
|
||||
page.offset !== 0 ||
|
||||
page.limit !== 1 ||
|
||||
page.time_filtered !== false
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return page.total as number;
|
||||
}
|
||||
|
||||
function identifiers(value: unknown): string[] | null {
|
||||
return list(value ?? [], (item) => text(item, ID_MAX));
|
||||
}
|
||||
@@ -430,6 +581,30 @@ function record(value: unknown): Record<string, unknown> | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
function exactRecord(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
): Record<string, unknown> | null {
|
||||
const item = record(value);
|
||||
return item &&
|
||||
Object.keys(item).length === keys.length &&
|
||||
keys.every((key) => Object.hasOwn(item, key))
|
||||
? item
|
||||
: null;
|
||||
}
|
||||
|
||||
function isoTime(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
ISO_TIME.test(value) &&
|
||||
Number.isFinite(Date.parse(value))
|
||||
);
|
||||
}
|
||||
|
||||
function nonemptyText(value: unknown, max: number): value is string {
|
||||
return typeof value === "string" && value.length > 0 && value.length <= max;
|
||||
}
|
||||
|
||||
function text(value: unknown, max: number): string | null {
|
||||
return typeof value === "string" && value.length <= max ? value : null;
|
||||
}
|
||||
|
||||
@@ -250,6 +250,20 @@ describe("models activate with an embedding change", () => {
|
||||
code: "embedding_switch_required",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat a domain 404 as an old-Core route miss", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 404,
|
||||
data: { detail: "embedding_switch_not_found" },
|
||||
});
|
||||
const result = await capturedRun(["models", "activate", PROFILE, "--json"]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(
|
||||
transport.post.mock.calls.some(([url]) =>
|
||||
String(url).endsWith("/activate"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("models switch and spaces", () => {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* makes it live. The CLI shows the plan, asks, starts the job and follows it.
|
||||
*/
|
||||
|
||||
import { isBareNotFound } from "@one-memory/operations";
|
||||
import type { CoreReader } from "./core-client.js";
|
||||
import type {
|
||||
CoreDeleteOperations,
|
||||
@@ -387,7 +388,11 @@ async function requestPlan(
|
||||
}
|
||||
if (outcome.state === "rejected") {
|
||||
// A Core before the registry has no plan route: activation as before.
|
||||
if (outcome.http_status === 404 && outcome.code === "http_404") {
|
||||
if (
|
||||
outcome.http_status === 404 &&
|
||||
outcome.code === "http_404" &&
|
||||
isBareNotFound(outcome.body)
|
||||
) {
|
||||
return { state: "unsupported" };
|
||||
}
|
||||
const plan = parsePlan(record(outcome.body)?.plan);
|
||||
|
||||
@@ -233,6 +233,31 @@ describe("memories add-status", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the retry code and time of an addition still waiting", async () => {
|
||||
transport.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
...addReceipt("pending"),
|
||||
error_code: "model_auth_failed",
|
||||
next_retry_at: "2026-11-02T10:00:00+00:00",
|
||||
},
|
||||
});
|
||||
const json = await capturedRun([
|
||||
"memories",
|
||||
"add-status",
|
||||
REQUEST,
|
||||
"--json",
|
||||
]);
|
||||
expect(JSON.parse(json.stdout).receipt).toMatchObject({
|
||||
state: "pending",
|
||||
error_code: "model_auth_failed",
|
||||
next_retry_at: "2026-11-02T10:00:00+00:00",
|
||||
});
|
||||
const text = await capturedRun(["memories", "add-status", REQUEST]);
|
||||
expect(text.stdout).toContain("error_code: model_auth_failed");
|
||||
expect(text.stdout).toContain("next_retry_at: 2026-11-02T10:00:00+00:00");
|
||||
});
|
||||
|
||||
it("reports an unknown request id as not_found, exit 1", async () => {
|
||||
transport.get.mockResolvedValue({
|
||||
status: 404,
|
||||
|
||||
@@ -248,6 +248,10 @@ function addReceipt(receipt: MemoryAddReceipt): Record<string, unknown> {
|
||||
task_id: receipt.task_id,
|
||||
memory_ids: receipt.memory_ids,
|
||||
error: receipt.error,
|
||||
error_code:
|
||||
typeof receipt.error_code === "string" ? receipt.error_code : null,
|
||||
next_retry_at:
|
||||
typeof receipt.next_retry_at === "string" ? receipt.next_retry_at : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -347,6 +351,12 @@ export function renderMemoryAddText(document: MemoryAddDocument): string {
|
||||
...((document.receipt.memory_ids as string[]).length > 0
|
||||
? [`memory_ids: ${(document.receipt.memory_ids as string[]).join(",")}`]
|
||||
: []),
|
||||
...(document.receipt.error_code
|
||||
? [`error_code: ${document.receipt.error_code}`]
|
||||
: []),
|
||||
...(document.receipt.next_retry_at
|
||||
? [`next_retry_at: ${document.receipt.next_retry_at}`]
|
||||
: []),
|
||||
document.command === "memories add"
|
||||
? `查询进度:memhub memories add-status ${document.request_id};重试请带上同一个 --request-id,不会重复添加。`
|
||||
: "",
|
||||
|
||||
@@ -161,6 +161,7 @@ const CORE = new Map<string, { status: number; data: unknown }>([
|
||||
blocked_stages: [
|
||||
{ kind: "extract", reason: "model_missing", count: 2 },
|
||||
],
|
||||
pipeline: { mode: "pro", configured_mode: "pro" },
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -202,7 +203,9 @@ const READ_ONLY_PATHS = [
|
||||
"/api/v1/live-sync",
|
||||
"/api/v1/memory-processing",
|
||||
"/api/v1/model-profiles",
|
||||
"/api/v2/home-projection",
|
||||
"/api/v2/home-summary",
|
||||
"/api/v2/raw",
|
||||
"/api/v2/memory-library",
|
||||
"/api/v2/memories/",
|
||||
"/api/v2/user-profile",
|
||||
@@ -257,6 +260,9 @@ describe("CLI-20 status summary", () => {
|
||||
expect(report.sections.model).toMatchObject({ state: "ok", profiles: 1 });
|
||||
expect(report.sections.memories).toMatchObject({
|
||||
state: "ok",
|
||||
content_plane: "memory",
|
||||
visible_count: 42,
|
||||
effective_mode: "pro",
|
||||
memory_count: 42,
|
||||
});
|
||||
expect(report.summary.state).toBe("ok");
|
||||
@@ -903,7 +909,10 @@ describe("CLI-21 memory reads", () => {
|
||||
expect(isReadOnlyPath("/api/v2/memories/ask")).toBe(false);
|
||||
expect(isReadOnlyPath("/api/v2/memories/m1")).toBe(true);
|
||||
expect(isReadOnlyPath("/api/v2/memory-library")).toBe(true);
|
||||
expect(isReadOnlyPath("/api/v2/home-projection")).toBe(true);
|
||||
expect(isReadOnlyPath("/api/v2/home-summary")).toBe(true);
|
||||
expect(isReadOnlyPath("/api/v2/raw")).toBe(true);
|
||||
expect(isReadOnlyPath("/api/v1/settings/memory-mode/plan")).toBe(true);
|
||||
// The retired v1 data plane is no longer a CLI read.
|
||||
expect(isReadOnlyPath("/api/v1/memories/m1")).toBe(false);
|
||||
expect(isReadOnlyPath("/api/v1/home-summary")).toBe(false);
|
||||
@@ -1085,7 +1094,8 @@ describe("malformed success responses", () => {
|
||||
|
||||
it("refuses a model profile whose string fields are not strings", async () => {
|
||||
transport.get.mockImplementation(async (url: string) => {
|
||||
if (url !== "/api/v1/model-profiles") return CORE.get(url);
|
||||
if (url !== "/api/v1/model-profiles")
|
||||
return CORE.get(url) ?? { status: 404, data: { detail: "not_found" } };
|
||||
return {
|
||||
status: 200,
|
||||
data: [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { CoreReader, ReadOutcome } from "./core-client.js";
|
||||
import { composeDoctorReport, renderDoctorText } from "./doctor-report.js";
|
||||
import {
|
||||
composeStatusReport,
|
||||
@@ -41,6 +42,108 @@ describe("status default output", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("status on a paused model", () => {
|
||||
const processing = (backoff: {
|
||||
llm_backoff_until: string | null;
|
||||
llm_backoff_error: string | null;
|
||||
}) => ({
|
||||
pending: 4,
|
||||
fresh_pending: 4,
|
||||
backlog_pending: 0,
|
||||
outside_window_pending: 0,
|
||||
running: 0,
|
||||
waiting_for_model: 0,
|
||||
completed: 1,
|
||||
failed: 0,
|
||||
blocked_stages: [],
|
||||
suspended: 2,
|
||||
...backoff,
|
||||
});
|
||||
const reader =
|
||||
(summary: unknown): CoreReader =>
|
||||
async <T>(path: string) =>
|
||||
(path === "/api/v1/memory-processing"
|
||||
? { state: "ok", data: summary }
|
||||
: { state: "unavailable", code: "x" }) as ReadOutcome<T>;
|
||||
const compose = (summary: unknown) =>
|
||||
composeStatusReport({
|
||||
dataDir: "/tmp/isolated/.one-memory",
|
||||
runtime: {
|
||||
state: "running",
|
||||
code: "runtime_verified",
|
||||
host: "127.0.0.1",
|
||||
port: 30001,
|
||||
},
|
||||
background: null,
|
||||
reader: reader(summary),
|
||||
});
|
||||
|
||||
it("names the code and the retry time while the breaker pauses", async () => {
|
||||
const report = await compose(
|
||||
processing({
|
||||
llm_backoff_until: "2026-11-02T10:00:00+00:00",
|
||||
llm_backoff_error: "model_auth_failed",
|
||||
}),
|
||||
);
|
||||
expect(report.sections.processing).toMatchObject({
|
||||
state: "ok",
|
||||
pending: 4,
|
||||
suspended: 2,
|
||||
llm_backoff_until: "2026-11-02T10:00:00+00:00",
|
||||
llm_backoff_error: "model_auth_failed",
|
||||
});
|
||||
const text = renderStatusText(report, "MemHub");
|
||||
expect(text).toContain(
|
||||
"模型暂停:model_auth_failed,将于 2026-11-02T10:00:00+00:00 自动重试",
|
||||
);
|
||||
expect(text).not.toContain("llm_backoff");
|
||||
});
|
||||
|
||||
it("prints no pause line and null fields when nothing is paused", async () => {
|
||||
const report = await compose(
|
||||
processing({ llm_backoff_until: null, llm_backoff_error: null }),
|
||||
);
|
||||
expect(report.sections.processing).toMatchObject({
|
||||
llm_backoff_until: null,
|
||||
llm_backoff_error: null,
|
||||
});
|
||||
expect(renderStatusText(report, "MemHub")).not.toContain("模型暂停");
|
||||
});
|
||||
|
||||
it("reports a restarting write drain and its exit code", async () => {
|
||||
const report = await compose({
|
||||
...processing({ llm_backoff_until: null, llm_backoff_error: null }),
|
||||
write_drain: "restarting",
|
||||
write_drain_error: "operational_error",
|
||||
});
|
||||
expect(report.sections.processing).toMatchObject({
|
||||
write_drain: "restarting",
|
||||
write_drain_error: "operational_error",
|
||||
});
|
||||
const text = renderStatusText(report, "MemHub");
|
||||
expect(text).toContain(
|
||||
"写入队列:异常退出(operational_error),正在自动重启",
|
||||
);
|
||||
expect(text).not.toContain("write_drain");
|
||||
});
|
||||
|
||||
it("says nothing about a running drain and nulls what an older Core omits", async () => {
|
||||
const running = await compose({
|
||||
...processing({ llm_backoff_until: null, llm_backoff_error: null }),
|
||||
write_drain: "running",
|
||||
write_drain_error: null,
|
||||
});
|
||||
expect(renderStatusText(running, "MemHub")).not.toContain("写入队列");
|
||||
const older = await compose(
|
||||
processing({ llm_backoff_until: null, llm_backoff_error: null }),
|
||||
);
|
||||
expect(older.sections.processing).toMatchObject({
|
||||
write_drain: null,
|
||||
write_drain_error: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("status and doctor on an unfinished upgrade migration", () => {
|
||||
it("names the migration log and the issues page", async () => {
|
||||
const report = await composeStatusReport({
|
||||
@@ -77,3 +180,140 @@ describe("status and doctor on an unfinished upgrade migration", () => {
|
||||
expect(report.runtime.log_file).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mode-aware status content projection", () => {
|
||||
const runtime = {
|
||||
state: "running" as const,
|
||||
code: "runtime_verified",
|
||||
host: "127.0.0.1",
|
||||
port: 30001,
|
||||
};
|
||||
const processing = (mode: "lite" | "pro" | "ultra") => ({
|
||||
pending: 0,
|
||||
fresh_pending: 0,
|
||||
backlog_pending: 0,
|
||||
outside_window_pending: 0,
|
||||
running: 0,
|
||||
waiting_for_model: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
pipeline: { mode, configured_mode: mode },
|
||||
});
|
||||
const projection = {
|
||||
projection_version: 1,
|
||||
user_id: "local",
|
||||
effective_mode: "lite",
|
||||
capabilities: {
|
||||
content_plane: "raw",
|
||||
generation_required: false,
|
||||
graph_enabled: false,
|
||||
evolve_available: false,
|
||||
},
|
||||
visible_count: 7,
|
||||
display_state: "excerpt",
|
||||
latest: {
|
||||
type: "raw",
|
||||
raw_id: "raw-1",
|
||||
session_id: "session-1",
|
||||
display_state: "excerpt",
|
||||
time: null,
|
||||
time_basis: null,
|
||||
excerpt: "hello",
|
||||
truncated: false,
|
||||
source_id: "cursor",
|
||||
},
|
||||
observed_at: "2026-11-04T00:00:00Z",
|
||||
};
|
||||
|
||||
it("uses the projection without reading either legacy count endpoint", async () => {
|
||||
const calls: string[] = [];
|
||||
const reader: CoreReader = async <T>(path: string) => {
|
||||
calls.push(path);
|
||||
const data =
|
||||
path === "/api/v2/home-projection"
|
||||
? projection
|
||||
: path === "/api/v1/memory-processing"
|
||||
? processing("lite")
|
||||
: null;
|
||||
return { state: "ok", data } as ReadOutcome<T>;
|
||||
};
|
||||
const report = await composeStatusReport({
|
||||
dataDir: "/tmp/isolated",
|
||||
runtime,
|
||||
background: null,
|
||||
reader,
|
||||
});
|
||||
expect(report.sections.memories).toEqual({
|
||||
state: "ok",
|
||||
content_plane: "raw",
|
||||
visible_count: 7,
|
||||
effective_mode: "lite",
|
||||
raw_count: 7,
|
||||
});
|
||||
expect(calls).not.toContain("/api/v2/raw");
|
||||
expect(calls).not.toContain("/api/v2/home-summary");
|
||||
expect(renderStatusText(report, "MemHub")).toContain(
|
||||
"原始对话:原始对话 7",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the real raw total only for an old Lite Core", async () => {
|
||||
const reader: CoreReader = async <T>(path: string) => {
|
||||
if (path === "/api/v2/home-projection")
|
||||
return { state: "not_found", code: "http_404" } as ReadOutcome<T>;
|
||||
if (path === "/api/v1/memory-processing")
|
||||
return {
|
||||
state: "ok",
|
||||
data: processing("lite"),
|
||||
} as ReadOutcome<T>;
|
||||
if (path === "/api/v2/raw")
|
||||
return {
|
||||
state: "ok",
|
||||
data: {
|
||||
user_id: "local",
|
||||
items: [],
|
||||
total: 9,
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
time_filtered: false,
|
||||
},
|
||||
} as ReadOutcome<T>;
|
||||
return { state: "unavailable", code: "x" } as ReadOutcome<T>;
|
||||
};
|
||||
const report = await composeStatusReport({
|
||||
dataDir: "/tmp/isolated",
|
||||
runtime,
|
||||
background: null,
|
||||
reader,
|
||||
});
|
||||
expect(report.sections.memories).toMatchObject({
|
||||
state: "ok",
|
||||
content_plane: "raw",
|
||||
visible_count: 9,
|
||||
raw_count: 9,
|
||||
});
|
||||
expect(report.sections.memories).not.toHaveProperty("memory_count");
|
||||
});
|
||||
|
||||
it("rejects extra projection fields instead of falling back or leaking them", async () => {
|
||||
const reader: CoreReader = async <T>(path: string) =>
|
||||
({
|
||||
state: "ok",
|
||||
data:
|
||||
path === "/api/v2/home-projection"
|
||||
? { ...projection, secret: "must-not-leak" }
|
||||
: processing("lite"),
|
||||
}) as ReadOutcome<T>;
|
||||
const report = await composeStatusReport({
|
||||
dataDir: "/tmp/isolated",
|
||||
runtime,
|
||||
background: null,
|
||||
reader,
|
||||
});
|
||||
expect(report.sections.memories).toEqual({
|
||||
state: "invalid",
|
||||
code: "response_invalid",
|
||||
});
|
||||
expect(JSON.stringify(report)).not.toContain("must-not-leak");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
HomeProjectionV1,
|
||||
LiveSyncHealth,
|
||||
MemoryProcessingSummary,
|
||||
ModelProfile,
|
||||
@@ -12,10 +13,12 @@ import { sdkMigrationLogPath } from "@one-memory/runtime-control";
|
||||
import type { CoreReader, ReadOutcome } from "./core-client.js";
|
||||
import {
|
||||
type SdkHomeSummary,
|
||||
validateHomeProjection,
|
||||
validateHomeSummary,
|
||||
validateLiveSync,
|
||||
validateModelProfiles,
|
||||
validateProcessing,
|
||||
validateRawTotal,
|
||||
validateSources,
|
||||
} from "./core-responses.js";
|
||||
import { describeEmbeddingSwitchSummary } from "./embedding-switch-flow.js";
|
||||
@@ -211,12 +214,12 @@ export async function composeStatusReport(options: {
|
||||
for (const name of SECTION_NAMES) sections[name] = { state, code };
|
||||
} else {
|
||||
const reader = options.reader;
|
||||
const [sources, sync, processing, model, memories] = await Promise.all([
|
||||
const [sources, sync, processing, model, projection] = await Promise.all([
|
||||
reader<unknown>("/api/v1/sources"),
|
||||
reader<unknown>("/api/v1/live-sync"),
|
||||
reader<unknown>("/api/v1/memory-processing"),
|
||||
reader<unknown>("/api/v1/model-profiles"),
|
||||
reader<unknown>("/api/v2/home-summary"),
|
||||
reader<unknown>("/api/v2/home-projection"),
|
||||
]);
|
||||
sections.sources = section(sources, validateSources, describeSources);
|
||||
sections.sync = section(sync, validateLiveSync, describeSync);
|
||||
@@ -228,11 +231,7 @@ export async function composeStatusReport(options: {
|
||||
pipeline =
|
||||
processing.state === "ok" ? describePipeline(processing.data) : null;
|
||||
sections.model = section(model, validateModelProfiles, describeModel);
|
||||
sections.memories = section(
|
||||
memories,
|
||||
validateHomeSummary,
|
||||
describeMemories,
|
||||
);
|
||||
sections.memories = await memorySection(reader, projection, pipeline);
|
||||
}
|
||||
// A bridge we could not inspect is unknown, not broken and not fine.
|
||||
const bridge: StatusDocument["bridge"] =
|
||||
@@ -312,7 +311,9 @@ export function renderStatusText(
|
||||
];
|
||||
for (const name of SECTION_NAMES) {
|
||||
lines.push(
|
||||
`${SECTION_LABELS[name]}:${sectionLine(report.sections[name])}`,
|
||||
`${sectionLabel(name, report.sections[name])}:${sectionLine(
|
||||
report.sections[name],
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
if (report.pipeline) lines.push(`记忆模式:${pipelineLine(report.pipeline)}`);
|
||||
@@ -320,6 +321,10 @@ export function renderStatusText(
|
||||
report.sections.processing?.embedding_switch,
|
||||
);
|
||||
if (embeddingSwitch !== null) lines.push(`向量模型:${embeddingSwitch}`);
|
||||
const backoff = modelBackoffLine(report.sections.processing);
|
||||
if (backoff !== null) lines.push(backoff);
|
||||
const drain = writeDrainLine(report.sections.processing);
|
||||
if (drain !== null) lines.push(drain);
|
||||
if (report.summary.state === "partial") {
|
||||
const notes = [
|
||||
report.summary.degraded.length > 0
|
||||
@@ -335,6 +340,35 @@ export function renderStatusText(
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** "Model paused" while Core's circuit breaker holds model work back; null
|
||||
* otherwise. Nothing to do here: the queue resumes by itself once the model
|
||||
* answers again (or after the model is fixed). */
|
||||
function modelBackoffLine(section: Section | undefined): string | null {
|
||||
if (section?.state !== "ok") return null;
|
||||
const error = section.llm_backoff_error;
|
||||
if (typeof error !== "string" || error.length === 0) return null;
|
||||
const until = section.llm_backoff_until;
|
||||
return `模型暂停:${error},${
|
||||
typeof until === "string" && until.length > 0
|
||||
? `将于 ${until} 自动重试`
|
||||
: "稍后自动重试"
|
||||
}`;
|
||||
}
|
||||
|
||||
/** The durable write drain, when it is not simply running: it exited on an
|
||||
* error and is coming back (`restarting`), or it is not running at all. */
|
||||
function writeDrainLine(section: Section | undefined): string | null {
|
||||
if (section?.state !== "ok") return null;
|
||||
const state = section.write_drain;
|
||||
if (state !== "restarting" && state !== "stopped") return null;
|
||||
const error = section.write_drain_error;
|
||||
const reason =
|
||||
typeof error === "string" && error.length > 0 ? `(${error})` : "";
|
||||
return state === "restarting"
|
||||
? `写入队列:异常退出${reason},正在自动重启`
|
||||
: `写入队列:已停止${reason}`;
|
||||
}
|
||||
|
||||
export function pipelineLine(pipeline: PipelineSummary): string {
|
||||
const parts: string[] = [pipeline.mode];
|
||||
if (pipeline.forced_by_env)
|
||||
@@ -384,15 +418,33 @@ function runtimeLine(report: StatusDocument): string {
|
||||
})`;
|
||||
}
|
||||
|
||||
/** Fields that have their own line (or none) instead of a place in the
|
||||
* section's one-line summary. */
|
||||
const SECTION_LINE_OMITTED = new Set([
|
||||
"state",
|
||||
"code",
|
||||
"embedding_switch",
|
||||
"llm_backoff_until",
|
||||
"llm_backoff_error",
|
||||
"write_drain",
|
||||
"write_drain_error",
|
||||
"content_plane",
|
||||
"visible_count",
|
||||
"effective_mode",
|
||||
]);
|
||||
|
||||
function sectionLabel(name: string, section: Section | undefined): string {
|
||||
return name === "memories" && section?.content_plane === "raw"
|
||||
? "原始对话"
|
||||
: SECTION_LABELS[name];
|
||||
}
|
||||
|
||||
function sectionLine(section: Section | undefined): string {
|
||||
if (!section) return "未检查";
|
||||
if (section.state === "not_checked") return "未检查";
|
||||
if (section.state !== "ok") return "暂时不可用,请稍后重试";
|
||||
return Object.entries(section)
|
||||
.filter(
|
||||
([key]) =>
|
||||
key !== "state" && key !== "code" && key !== "embedding_switch",
|
||||
)
|
||||
.filter(([key]) => !SECTION_LINE_OMITTED.has(key))
|
||||
.map(([key, value]) => `${fieldLabel(key)} ${formatValue(value)}`)
|
||||
.join(" ");
|
||||
}
|
||||
@@ -428,6 +480,7 @@ function fieldLabel(key: string): string {
|
||||
backlog_pending: "历史待处理",
|
||||
outside_window_pending: "稍后处理",
|
||||
waiting_for_model: "等待模型",
|
||||
suspended: "来源已暂停",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
blocked: "卡住",
|
||||
@@ -437,6 +490,10 @@ function fieldLabel(key: string): string {
|
||||
profiles: "配置",
|
||||
active: "当前",
|
||||
memory_count: "记忆",
|
||||
raw_count: "原始对话",
|
||||
visible_count: "可见",
|
||||
content_plane: "内容层",
|
||||
effective_mode: "模式",
|
||||
};
|
||||
return labels[key] ?? key;
|
||||
}
|
||||
@@ -508,10 +565,47 @@ function describeProcessing(
|
||||
reason: block.reason,
|
||||
count: block.count,
|
||||
})),
|
||||
suspended: typeof summary.suspended === "number" ? summary.suspended : 0,
|
||||
...llmBackoffFields(summary),
|
||||
...writeDrainFields(summary),
|
||||
...embeddingSwitchField(summary),
|
||||
};
|
||||
}
|
||||
|
||||
/** The model circuit breaker: set only while Core pauses model work because
|
||||
* the provider keeps failing. Both null when nothing is paused. */
|
||||
function llmBackoffFields(summary: MemoryProcessingSummary): {
|
||||
llm_backoff_until: string | null;
|
||||
llm_backoff_error: string | null;
|
||||
} {
|
||||
const until = summary.llm_backoff_until;
|
||||
const error = summary.llm_backoff_error;
|
||||
return {
|
||||
llm_backoff_until:
|
||||
typeof until === "string" && until.length <= 64 ? until : null,
|
||||
llm_backoff_error:
|
||||
typeof error === "string" && /^[a-z_]{1,64}$/.test(error) ? error : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Durable write drain liveness. Null when Core does not report it (older
|
||||
* Core, or recovery has not started the drain yet). */
|
||||
function writeDrainFields(summary: MemoryProcessingSummary): {
|
||||
write_drain: "running" | "restarting" | "stopped" | null;
|
||||
write_drain_error: string | null;
|
||||
} {
|
||||
const state = summary.write_drain;
|
||||
const error = summary.write_drain_error;
|
||||
return {
|
||||
write_drain:
|
||||
state === "running" || state === "restarting" || state === "stopped"
|
||||
? state
|
||||
: null,
|
||||
write_drain_error:
|
||||
typeof error === "string" && /^[a-z_]{1,64}$/.test(error) ? error : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The running switch or merge, projected; absent when none runs. */
|
||||
function embeddingSwitchField(
|
||||
summary: MemoryProcessingSummary,
|
||||
@@ -552,6 +646,70 @@ function describeMemories(summary: SdkHomeSummary): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
async function memorySection(
|
||||
reader: CoreReader,
|
||||
projection: ReadOutcome<unknown>,
|
||||
pipeline: PipelineSummary | null,
|
||||
): Promise<Section> {
|
||||
if (projection.state === "ok") {
|
||||
const parsed = validateHomeProjection(projection.data);
|
||||
return parsed === null
|
||||
? { state: "invalid", code: "response_invalid" }
|
||||
: { state: "ok", ...describeProjection(parsed) };
|
||||
}
|
||||
if (projection.state !== "not_found") {
|
||||
return {
|
||||
state: projection.state === "denied" ? "denied" : "unavailable",
|
||||
code: projection.code,
|
||||
};
|
||||
}
|
||||
if (pipeline === null) {
|
||||
return { state: "unavailable", code: "effective_mode_unavailable" };
|
||||
}
|
||||
if (pipeline.mode === "lite") {
|
||||
const raw = await reader<unknown>("/api/v2/raw", {
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
});
|
||||
if (raw.state !== "ok") {
|
||||
return {
|
||||
state: raw.state === "denied" ? "denied" : "unavailable",
|
||||
code: raw.code,
|
||||
};
|
||||
}
|
||||
const total = validateRawTotal(raw.data);
|
||||
return total === null
|
||||
? { state: "invalid", code: "response_invalid" }
|
||||
: {
|
||||
state: "ok",
|
||||
content_plane: "raw",
|
||||
visible_count: total,
|
||||
effective_mode: "lite",
|
||||
raw_count: total,
|
||||
};
|
||||
}
|
||||
const legacy = await reader<unknown>("/api/v2/home-summary");
|
||||
return section(legacy, validateHomeSummary, (summary) => ({
|
||||
content_plane: "memory",
|
||||
visible_count: summary.memory_count,
|
||||
effective_mode: pipeline.mode,
|
||||
...describeMemories(summary),
|
||||
}));
|
||||
}
|
||||
|
||||
function describeProjection(
|
||||
projection: HomeProjectionV1,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
content_plane: projection.capabilities.content_plane,
|
||||
visible_count: projection.visible_count,
|
||||
effective_mode: projection.effective_mode,
|
||||
...(projection.capabilities.content_plane === "raw"
|
||||
? { raw_count: projection.visible_count }
|
||||
: { memory_count: projection.visible_count }),
|
||||
};
|
||||
}
|
||||
|
||||
function sum(
|
||||
sources: SourceResponse[],
|
||||
key: "imported_conversations" | "imported_messages",
|
||||
|
||||
@@ -208,6 +208,27 @@ describe("--user-id on the memory commands", () => {
|
||||
expect(direct.get.mock.calls[0][1].params).toEqual({ user_id: "wife" });
|
||||
});
|
||||
|
||||
it("prints the retry code and time of a write still waiting", async () => {
|
||||
direct.get.mockResolvedValue({
|
||||
data: {
|
||||
request_id: REQUEST,
|
||||
state: "accepted",
|
||||
created_at: "T1",
|
||||
updated_at: "T2",
|
||||
error_code: "model_timeout",
|
||||
next_retry_at: "2026-11-02T10:00:00+00:00",
|
||||
},
|
||||
});
|
||||
const text = await capturedRun(["write", "status", REQUEST]);
|
||||
expect(text.stdout).toContain("error_code: model_timeout");
|
||||
expect(text.stdout).toContain("next_retry_at: 2026-11-02T10:00:00+00:00");
|
||||
const json = await capturedRun(["write", "status", REQUEST, "--json"]);
|
||||
expect(JSON.parse(json.stdout).write).toMatchObject({
|
||||
error_code: "model_timeout",
|
||||
next_retry_at: "2026-11-02T10:00:00+00:00",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the user in the body of memory writes and searches", async () => {
|
||||
await capturedRun(["memories", "add", "Prefers tea", "--user-id", "wife"]);
|
||||
expect(transport.post.mock.calls[0][0]).toBe("/api/v2/memories");
|
||||
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"fastapi>=0.141.1",
|
||||
"filelock>=3.32.4",
|
||||
# Public SDK version validated against MemHub's permission and recall contracts.
|
||||
"hy-memory==2.0.12",
|
||||
"hy-memory==2.0.14",
|
||||
# chromadb arrives through the HY SDK; locked here so an unrelated upgrade
|
||||
# cannot move the SDK's store. Built-in embedding consumes numpy directly.
|
||||
"chromadb==1.5.9",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -15,6 +15,20 @@ BULK_WORKERS = 3
|
||||
SNAPSHOT_WORKERS = 8
|
||||
INTERACTIVE_READ_WORKERS = 4
|
||||
SOURCE_READER_WORKERS = 4
|
||||
# One per source the drain runs side by side (`max_concurrent_sources`), and
|
||||
# two over for the outcome inquiries that follow them.
|
||||
SDK_WRITE_WORKERS = 12
|
||||
# Recall's SDK reads: one search and the `get`s behind it (`_SDK_GET_CONCURRENCY`).
|
||||
SDK_READ_WORKERS = 8
|
||||
# The durable write queue's own bookkeeping: claim, settle, the per-job reads
|
||||
# around an SDK write, and the drain's maintenance passes. Each is one short
|
||||
# statement on the single writer or a reader; the drain runs at most
|
||||
# `max_concurrent_sources` jobs, and they wait for the writer, not for CPU.
|
||||
DURABLE_QUEUE_WORKERS = 4
|
||||
# Lease renewal alone. Renewal is the drain's ownership fence: a lease that
|
||||
# lapses while its SDK write is still running hands the job to recovery, so it
|
||||
# must never wait for a thread behind any other work, its own queue's included.
|
||||
LEASE_RENEWAL_WORKERS = 2
|
||||
|
||||
_pool: ThreadPoolExecutor | None = None
|
||||
_snapshot_pool: ThreadPoolExecutor | None = None
|
||||
@@ -22,6 +36,9 @@ _interactive_pool: ThreadPoolExecutor | None = None
|
||||
_raw_read_pool: ThreadPoolExecutor | None = None
|
||||
_memory_read_pool: ThreadPoolExecutor | None = None
|
||||
_source_reader_pool: ThreadPoolExecutor | None = None
|
||||
_sdk_write_pool: ThreadPoolExecutor | None = None
|
||||
_durable_queue_pool: ThreadPoolExecutor | None = None
|
||||
_lease_renewal_pool: ThreadPoolExecutor | None = None
|
||||
_warmed_readers: weakref.WeakSet[ThreadPoolExecutor] = weakref.WeakSet()
|
||||
_reader_warm_lock = threading.Lock()
|
||||
_interactive_reads = 0
|
||||
@@ -71,15 +88,67 @@ def raw_read_pool() -> ThreadPoolExecutor:
|
||||
|
||||
|
||||
def memory_read_pool() -> ThreadPoolExecutor:
|
||||
"""Recall's own threads: SDK searches, gets, and what decides between them.
|
||||
|
||||
Recall used to hand these to the default pool, which SDK writes share. A
|
||||
write that times out keeps its thread until the SDK returns (#231), so a
|
||||
few stuck writes left recall waiting for a thread while `/health`, which
|
||||
needs none, still answered.
|
||||
"""
|
||||
global _memory_read_pool
|
||||
if _memory_read_pool is None:
|
||||
_memory_read_pool = ThreadPoolExecutor(
|
||||
max_workers=INTERACTIVE_READ_WORKERS,
|
||||
max_workers=SDK_READ_WORKERS,
|
||||
thread_name_prefix="one-memory-memory-read",
|
||||
)
|
||||
return _memory_read_pool
|
||||
|
||||
|
||||
def sdk_write_pool() -> ThreadPoolExecutor:
|
||||
"""SDK writes, bounded and apart from everything else.
|
||||
|
||||
Each thread here only waits for the SDK's own loop thread to answer
|
||||
(`_LoopThread.run`), so any thread outside that loop may make the call; a
|
||||
stuck write therefore costs a thread of this pool and nobody else's.
|
||||
"""
|
||||
global _sdk_write_pool
|
||||
if _sdk_write_pool is None:
|
||||
_sdk_write_pool = ThreadPoolExecutor(
|
||||
max_workers=SDK_WRITE_WORKERS,
|
||||
thread_name_prefix="one-memory-sdk-write",
|
||||
)
|
||||
return _sdk_write_pool
|
||||
|
||||
|
||||
def durable_queue_pool() -> ThreadPoolExecutor:
|
||||
"""The durable write queue's database work, apart from bulk work.
|
||||
|
||||
The queue used to hand its claims, settles and maintenance to the bulk
|
||||
pool, whose three threads also carry import heartbeats and job listings.
|
||||
Under an import peak all three sat in those for tens of seconds, and the
|
||||
queue's next statement waited for a thread before it could even join the
|
||||
writer's line, and leases lapsed while their SDK writes were still running.
|
||||
"""
|
||||
global _durable_queue_pool
|
||||
if _durable_queue_pool is None:
|
||||
_durable_queue_pool = ThreadPoolExecutor(
|
||||
max_workers=DURABLE_QUEUE_WORKERS,
|
||||
thread_name_prefix="one-memory-write-queue",
|
||||
)
|
||||
return _durable_queue_pool
|
||||
|
||||
|
||||
def lease_renewal_pool() -> ThreadPoolExecutor:
|
||||
"""Threads that only renew durable write leases (`DurableWriteDrain.renew`)."""
|
||||
global _lease_renewal_pool
|
||||
if _lease_renewal_pool is None:
|
||||
_lease_renewal_pool = ThreadPoolExecutor(
|
||||
max_workers=LEASE_RENEWAL_WORKERS,
|
||||
thread_name_prefix="one-memory-lease-renewal",
|
||||
)
|
||||
return _lease_renewal_pool
|
||||
|
||||
|
||||
def source_reader_pool() -> ThreadPoolExecutor:
|
||||
"""Bounded process-wide pool for source iteration and parsing."""
|
||||
global _source_reader_pool
|
||||
@@ -99,9 +168,21 @@ def warm_bulk_pool() -> None:
|
||||
Starting a thread is nothing on an idle machine and 2.5 seconds on a busy
|
||||
one, which is precisely when bulk work is being handed over. So it is
|
||||
done here instead, at startup, before there is anything to be late for.
|
||||
|
||||
The durable write queue's pools are warmed here too, for the same reason:
|
||||
their first renewal must not pay for starting a thread.
|
||||
"""
|
||||
pool = bulk_pool()
|
||||
started = tuple(threading.Event() for _ in range(BULK_WORKERS))
|
||||
_start_every_worker(bulk_pool(), BULK_WORKERS, "bulk_pool_warmup_timeout")
|
||||
_start_every_worker(
|
||||
durable_queue_pool(), DURABLE_QUEUE_WORKERS, "durable_queue_pool_warmup_timeout"
|
||||
)
|
||||
_start_every_worker(
|
||||
lease_renewal_pool(), LEASE_RENEWAL_WORKERS, "lease_renewal_pool_warmup_timeout"
|
||||
)
|
||||
|
||||
|
||||
def _start_every_worker(pool: ThreadPoolExecutor, workers: int, timeout_code: str) -> None:
|
||||
started = tuple(threading.Event() for _ in range(workers))
|
||||
release = threading.Event()
|
||||
|
||||
def occupy(marker: threading.Event) -> None:
|
||||
@@ -117,7 +198,7 @@ def warm_bulk_pool() -> None:
|
||||
try:
|
||||
for marker in started:
|
||||
if not marker.wait(10):
|
||||
raise RuntimeError("bulk_pool_warmup_timeout")
|
||||
raise RuntimeError(timeout_code)
|
||||
finally:
|
||||
release.set()
|
||||
for future in futures:
|
||||
@@ -175,6 +256,62 @@ async def in_interactive_read[T](
|
||||
return await in_read_pool(interactive_read_pool(), work, *args, **kwargs)
|
||||
|
||||
|
||||
async def in_sdk_read[T](
|
||||
work: Callable[..., T],
|
||||
/,
|
||||
*args: object,
|
||||
**kwargs: object,
|
||||
) -> T:
|
||||
"""A recall-path read, in recall's own pool (`memory_read_pool`)."""
|
||||
return await in_read_pool(memory_read_pool(), work, *args, **kwargs)
|
||||
|
||||
|
||||
async def in_sdk_write[T](
|
||||
work: Callable[..., T],
|
||||
/,
|
||||
*args: object,
|
||||
**kwargs: object,
|
||||
) -> T:
|
||||
"""One SDK write call, in the write pool (`sdk_write_pool`)."""
|
||||
return await asyncio.get_running_loop().run_in_executor(
|
||||
sdk_write_pool(),
|
||||
copy_context().run,
|
||||
partial(work, *args, **kwargs),
|
||||
)
|
||||
|
||||
|
||||
async def in_the_write_queue[T](
|
||||
work: Callable[..., T],
|
||||
/,
|
||||
*args: object,
|
||||
**kwargs: object,
|
||||
) -> T:
|
||||
"""One durable write queue statement, in the queue's pool (`durable_queue_pool`).
|
||||
|
||||
It still goes through the one writer and its line, like every write; what
|
||||
it no longer does is wait for a bulk thread first.
|
||||
"""
|
||||
return await asyncio.get_running_loop().run_in_executor(
|
||||
durable_queue_pool(),
|
||||
copy_context().run,
|
||||
partial(work, *args, **kwargs),
|
||||
)
|
||||
|
||||
|
||||
async def in_lease_renewal[T](
|
||||
work: Callable[..., T],
|
||||
/,
|
||||
*args: object,
|
||||
**kwargs: object,
|
||||
) -> T:
|
||||
"""One lease renewal, in the threads kept for nothing else (`lease_renewal_pool`)."""
|
||||
return await asyncio.get_running_loop().run_in_executor(
|
||||
lease_renewal_pool(),
|
||||
copy_context().run,
|
||||
partial(work, *args, **kwargs),
|
||||
)
|
||||
|
||||
|
||||
def _retrieve[T](future: "asyncio.Future[T]") -> None:
|
||||
"""Mark a shielded read's outcome as seen.
|
||||
|
||||
@@ -261,7 +398,16 @@ def _warm_reader_pool(reader: ThreadPoolExecutor) -> None:
|
||||
|
||||
def shutdown_bulk_pool() -> None:
|
||||
global _pool, _snapshot_pool, _interactive_pool, _raw_read_pool, _memory_read_pool
|
||||
global _source_reader_pool
|
||||
global _source_reader_pool, _sdk_write_pool, _durable_queue_pool, _lease_renewal_pool
|
||||
if _durable_queue_pool is not None:
|
||||
_durable_queue_pool.shutdown(wait=False, cancel_futures=True)
|
||||
_durable_queue_pool = None
|
||||
if _lease_renewal_pool is not None:
|
||||
_lease_renewal_pool.shutdown(wait=False, cancel_futures=True)
|
||||
_lease_renewal_pool = None
|
||||
if _sdk_write_pool is not None:
|
||||
_sdk_write_pool.shutdown(wait=False, cancel_futures=True)
|
||||
_sdk_write_pool = None
|
||||
if _pool is not None:
|
||||
_pool.shutdown(wait=False, cancel_futures=True)
|
||||
_pool = None
|
||||
|
||||
@@ -66,6 +66,8 @@ class SdkContextSearchRequest(ContextSearchRequest, SdkSearchOptions):
|
||||
limit: int = Field(default=10, ge=1, le=20, strict=True)
|
||||
schema_limit: int = Field(default=3, ge=0, le=20, strict=True)
|
||||
intention_limit: int = Field(default=1, ge=0, le=20, strict=True)
|
||||
#: Product callers can request one item per turn instead of SDK raw batches.
|
||||
raw_unit: Literal["raw", "turn"] = "raw"
|
||||
#: Whose memories to search. The App never sends it and reads `local`.
|
||||
user_id: UserId = DEFAULT_USER_ID
|
||||
|
||||
@@ -123,6 +125,9 @@ class SdkRawContextItem(BaseModel):
|
||||
memory_at: datetime | None = None
|
||||
score: float = Field(ge=0, le=1)
|
||||
truncated: bool = False
|
||||
turn_id: str | None = Field(default=None, min_length=1, max_length=96)
|
||||
turn_index: int | None = Field(default=None, ge=0)
|
||||
turn_count: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class SdkMemoryContextItem(BaseModel):
|
||||
|
||||
@@ -30,6 +30,7 @@ from one_memory.hy_sdk_types import (
|
||||
parse_sdk_search,
|
||||
)
|
||||
from one_memory.imports.storage import EvidenceStore
|
||||
from one_memory.raw_turns import raw_display_turns, turns_for_hit
|
||||
from one_memory.sdk_search_options import SdkSearchFilters, SearchLane
|
||||
from one_memory.semantic_deadline import SemanticMemoryHit
|
||||
from one_memory.user_scope import owns
|
||||
@@ -70,6 +71,18 @@ def raw_lane_overrides(value: SdkSearchFilters) -> dict[str, object]:
|
||||
return found
|
||||
|
||||
|
||||
def raw_lane_statuses(*, lite: bool) -> dict[str, list[str]]:
|
||||
"""Which raw statuses the raw lane searches.
|
||||
|
||||
Lite extracts nothing, so its search is plain RAG over every raw,
|
||||
including the shadow raws pro already processed before the switch to
|
||||
lite. Pro and ultra keep the SDK default (active only): a shadow raw is
|
||||
already represented by the memories extracted from it, and returning it
|
||||
again would repeat that content in the caller's context budget.
|
||||
"""
|
||||
return {"statuses": ["active", "shadow"]} if lite else {}
|
||||
|
||||
|
||||
def hit_namespace(hit: SemanticMemoryHit) -> dict[str, str]:
|
||||
"""The `get` namespace of a hit: nothing for chat, kind and variant otherwise."""
|
||||
if hit.kind in {"experience", "fact"} and hit.variant is not None:
|
||||
@@ -120,6 +133,9 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
sources = raw_lane_sources(authority.raw_sources, value.include_sources)
|
||||
if sources is None:
|
||||
return ()
|
||||
# Plain RAG: every vector hit over the floor is an answer, ranked
|
||||
# by its score. Each hit yields at least one item, so the SDK
|
||||
# needs to return no more hits than the caller's limit.
|
||||
raw = await asyncio.to_thread(
|
||||
client.search,
|
||||
value.query,
|
||||
@@ -135,27 +151,60 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
"scene": "chat",
|
||||
"reader": HY_MEMORY_READER,
|
||||
"layers": ["l1_raw"],
|
||||
**raw_lane_statuses(lite=lite),
|
||||
**raw_lane_overrides(value),
|
||||
},
|
||||
)
|
||||
hits = tuple(
|
||||
hit for hit in parse_sdk_raw_search(raw) if value.passes_floor("raw", hit.score)
|
||||
)
|
||||
return tuple(
|
||||
SdkRawContextItem(
|
||||
raw_id=hit.raw_id,
|
||||
content=hit.content,
|
||||
source=hit.source,
|
||||
session_id=hit.session_id,
|
||||
memory_at=(
|
||||
datetime.fromtimestamp(hit.memory_at, tz=UTC)
|
||||
if hit.memory_at is not None
|
||||
else None
|
||||
),
|
||||
score=hit.score,
|
||||
# One item per hit first, so a hit is never crowded out by another
|
||||
# hit's sibling turns; the siblings fill whatever room is left.
|
||||
primary: list[SdkRawContextItem] = []
|
||||
siblings: list[SdkRawContextItem] = []
|
||||
for hit in hits:
|
||||
memory_at = (
|
||||
datetime.fromtimestamp(hit.memory_at, tz=UTC)
|
||||
if hit.memory_at is not None
|
||||
else None
|
||||
)
|
||||
for hit in hits
|
||||
)
|
||||
turns = raw_display_turns(hit) if value.raw_unit == "turn" else ()
|
||||
if not turns:
|
||||
primary.append(
|
||||
SdkRawContextItem(
|
||||
raw_id=hit.raw_id,
|
||||
content=hit.content,
|
||||
source=hit.source,
|
||||
session_id=hit.session_id,
|
||||
memory_at=memory_at,
|
||||
score=hit.score,
|
||||
)
|
||||
)
|
||||
continue
|
||||
# The embedding is the batch's: the hit is shown by the turns
|
||||
# that contain the query, or else by its closest turn, each
|
||||
# with the hit's score.
|
||||
shown = [
|
||||
SdkRawContextItem(
|
||||
raw_id=hit.raw_id,
|
||||
turn_id=turn.turn_id,
|
||||
turn_index=turn.turn_index,
|
||||
turn_count=turn.turn_count,
|
||||
content=turn.content,
|
||||
source=hit.source,
|
||||
session_id=hit.session_id,
|
||||
memory_at=memory_at,
|
||||
score=hit.score,
|
||||
)
|
||||
for turn in turns_for_hit(turns, value.query)
|
||||
]
|
||||
primary.append(shown[0])
|
||||
siblings.extend(shown[1:])
|
||||
chosen = primary[: value.limit]
|
||||
chosen.extend(siblings[: value.limit - len(chosen)])
|
||||
# Stable: a hit's turns stay together, in hit-score order.
|
||||
chosen.sort(key=lambda item: item.score, reverse=True)
|
||||
return tuple(chosen)
|
||||
|
||||
async def memory_lane() -> tuple[SdkMemoryContextItem, ...]:
|
||||
# lite extracts nothing: its search answers raw turns, which the
|
||||
|
||||
@@ -111,10 +111,26 @@ MAIN_DATABASE_CHECKPOINT_WRITE_INTERVAL = 64
|
||||
MAIN_DATABASE_CHECKPOINT_SECONDS = 30.0
|
||||
|
||||
# Advisory only: PASSIVE cannot force a frame past a long reader. Crossing this
|
||||
# threshold is a signal that the WAL is not converging and that read snapshots
|
||||
# or write volume need investigation.
|
||||
# threshold with frames still waiting to be copied back is a signal that the
|
||||
# WAL is not converging and that read snapshots or write volume need
|
||||
# investigation. A large file whose frames are all checkpointed is not: the
|
||||
# idle TRUNCATE below gives that space back.
|
||||
MAIN_DATABASE_WAL_WARN_BYTES = 512 * 1024 * 1024
|
||||
|
||||
# What a WAL file is cut back to once a checkpoint has copied every frame and
|
||||
# the writer starts it over. Without a limit SQLite reuses the file in place
|
||||
# and never shrinks it, so one import peak left a 639 MB WAL behind for good
|
||||
# (#245). 64 MiB is about sixteen times what the default autocheckpoint (1000
|
||||
# pages) lets build up between checkpoints, so ordinary write bursts reuse the
|
||||
# file without growing and shrinking it each time, and it is still a tenth of
|
||||
# what that user's disk was left holding.
|
||||
MAIN_DATABASE_JOURNAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024
|
||||
|
||||
# How long an idle TRUNCATE may wait for the writer or for a reader to leave.
|
||||
# It runs only when nothing holds the database; anyone arriving in the
|
||||
# meantime wins, and the checkpoint is tried again on the next pass.
|
||||
MAIN_DATABASE_IDLE_CHECKPOINT_WAIT_MS = 50
|
||||
|
||||
|
||||
def checkpoint_write_interval() -> int:
|
||||
raw = os.environ.get(
|
||||
@@ -226,6 +242,75 @@ class _InTurn:
|
||||
self._line.remove(place)
|
||||
|
||||
|
||||
class _Checkpointer:
|
||||
"""The one thread that runs cadence checkpoints, for every main database.
|
||||
|
||||
A database asks for at most one checkpoint at a time: a request made
|
||||
while one is already waiting is the same request. The checkpoint goes
|
||||
through the database's own writer, in its line, like any other write.
|
||||
A waiting request holds the database weakly: it must not keep alive a
|
||||
database nobody else holds any more.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._state = threading.Condition()
|
||||
self._due: OrderedDict[int, weakref.ref[MainDatabase]] = OrderedDict()
|
||||
self._running = 0
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def request(self, database: "MainDatabase") -> None:
|
||||
with self._state:
|
||||
self._due.setdefault(id(database), weakref.ref(database))
|
||||
if self._thread is None or not self._thread.is_alive():
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name="one-memory-checkpoint",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
self._state.notify_all()
|
||||
|
||||
def forget(self, database: "MainDatabase") -> None:
|
||||
with self._state:
|
||||
self._due.pop(id(database), None)
|
||||
self._state.notify_all()
|
||||
|
||||
def wait_idle(self, timeout: float) -> bool:
|
||||
"""Whether every requested checkpoint has run within `timeout` seconds."""
|
||||
with self._state:
|
||||
return self._state.wait_for(lambda: not self._due and not self._running, timeout)
|
||||
|
||||
def _run(self) -> None:
|
||||
while True:
|
||||
with self._state:
|
||||
self._state.wait_for(lambda: bool(self._due))
|
||||
_key, reference = self._due.popitem(last=False)
|
||||
database = reference()
|
||||
if database is None:
|
||||
self._state.notify_all()
|
||||
continue
|
||||
self._running += 1
|
||||
try:
|
||||
database._cadence_checkpoint()
|
||||
except Exception: # noqa: BLE001 - maintenance must not end the thread
|
||||
logger.warning(
|
||||
"main_database_checkpoint_failed file=%s", database.path.name, exc_info=True
|
||||
)
|
||||
finally:
|
||||
del database
|
||||
with self._state:
|
||||
self._running -= 1
|
||||
self._state.notify_all()
|
||||
|
||||
|
||||
_checkpointer = _Checkpointer()
|
||||
|
||||
|
||||
def wait_for_cadence_checkpoints(timeout: float = 5.0) -> bool:
|
||||
"""Wait for the cadence checkpoints already requested; for tests and shutdown."""
|
||||
return _checkpointer.wait_idle(timeout)
|
||||
|
||||
|
||||
def _locked() -> sqlite3.OperationalError:
|
||||
# Callers tell a busy database from a broken one by these, as they would
|
||||
# for SQLite's own refusal.
|
||||
@@ -276,6 +361,8 @@ class MainDatabase:
|
||||
self._writes_since_checkpoint = 0
|
||||
self._last_checkpoint_at = time.monotonic()
|
||||
self._wal_warned_at = 0.0
|
||||
#: Learned from the first checkpoint; turns WAL frames into bytes.
|
||||
self._page_size = 4096
|
||||
self._reader_started_at: dict[int, float] = {}
|
||||
|
||||
@contextmanager
|
||||
@@ -542,24 +629,77 @@ class MainDatabase:
|
||||
def _checkpoint_from_maintenance(self, mode: str) -> tuple[int, int, int]:
|
||||
return self._checkpoint(mode, maintenance=True)
|
||||
|
||||
def _checkpoint(self, mode: str, *, maintenance: bool) -> tuple[int, int, int]:
|
||||
def _checkpoint(
|
||||
self,
|
||||
mode: str,
|
||||
*,
|
||||
maintenance: bool,
|
||||
busy_timeout_ms: int = WRITE_LOCK_WAIT_MS,
|
||||
) -> tuple[int, int, int]:
|
||||
normalized = mode.strip().upper()
|
||||
if normalized not in {"PASSIVE", "FULL", "RESTART", "TRUNCATE"}:
|
||||
raise ValueError("unsupported WAL checkpoint mode")
|
||||
with self.write(autocommit=True, maintenance=maintenance) as connection:
|
||||
with self.write(
|
||||
autocommit=True, maintenance=maintenance, busy_timeout_ms=busy_timeout_ms
|
||||
) as connection:
|
||||
row = connection.execute(f"PRAGMA wal_checkpoint({normalized})").fetchone()
|
||||
page_size = connection.execute("PRAGMA page_size").fetchone()
|
||||
if row is None or len(row) != 3:
|
||||
raise sqlite3.DatabaseError("SQLite returned an invalid checkpoint result")
|
||||
if page_size is not None:
|
||||
self._page_size = int(page_size[0])
|
||||
return int(row[0]), int(row[1]), int(row[2])
|
||||
|
||||
def idle(self) -> bool:
|
||||
"""Nobody holds or waits for a connection to this database right now."""
|
||||
with self._state:
|
||||
return (
|
||||
self._writer_owner is None
|
||||
and not self._reader_holders
|
||||
and not len(self._write_line)
|
||||
and not len(self._read_line)
|
||||
)
|
||||
|
||||
def checkpoint_if_idle(self) -> tuple[int, int, int] | None:
|
||||
"""Give the WAL file back to the disk while nothing is using the database.
|
||||
|
||||
PASSIVE copies frames back but leaves the file its size; only TRUNCATE
|
||||
empties it, and TRUNCATE has to wait for every reader to finish, so it
|
||||
is attempted only when this process holds no checkout at all, and with
|
||||
a wait of a few milliseconds. A busy result, or someone arriving in
|
||||
between, is not a failure: the next idle pass tries again. Returns the
|
||||
checkpoint result, or None when it was not attempted or not admitted.
|
||||
"""
|
||||
if not self.idle() or self._wal_bytes() == 0:
|
||||
return None
|
||||
try:
|
||||
return self._checkpoint(
|
||||
"TRUNCATE",
|
||||
maintenance=True,
|
||||
busy_timeout_ms=MAIN_DATABASE_IDLE_CHECKPOINT_WAIT_MS,
|
||||
)
|
||||
except sqlite3.Error:
|
||||
logger.debug("main_database_idle_checkpoint_skipped file=%s", self.path.name)
|
||||
return None
|
||||
|
||||
def _maybe_checkpoint_after_write(self) -> None:
|
||||
"""Checkpoint on a write cadence without opening another connection.
|
||||
"""Count a write, and hand a due cadence checkpoint to the checkpointer.
|
||||
|
||||
SQLite's default autocheckpoint only runs when the writer itself
|
||||
crosses the page threshold. Production hit a state where the WAL grew
|
||||
to 13GB, so the main database needs its own bounded, observable
|
||||
maintenance path. This runs after the caller's checkout has been reset
|
||||
and only re-enters the same serialized writer queue.
|
||||
maintenance path: every `checkpoint_write_interval()` writes or
|
||||
`MAIN_DATABASE_CHECKPOINT_SECONDS`, whichever comes first.
|
||||
|
||||
The checkpoint itself used to run right here, on the thread that had
|
||||
just written, as one more turn in the writer's line. That thread was
|
||||
an import heartbeat or a durable lease renewal as often as not, and
|
||||
it then waited behind the whole line a second time before it could
|
||||
return. The idle TRUNCATE (#278) cannot replace the cadence: under a
|
||||
sustained import the database is never idle, and that is exactly when
|
||||
the WAL grows. So the cadence stays, and one checkpointer thread runs
|
||||
it (`_Checkpointer`); the writer stays the only connection that
|
||||
writes, and no caller waits for maintenance it did not ask for.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
with self._state:
|
||||
@@ -571,25 +711,35 @@ class MainDatabase:
|
||||
if due:
|
||||
self._writes_since_checkpoint = 0
|
||||
self._last_checkpoint_at = now
|
||||
if not due:
|
||||
return
|
||||
if due:
|
||||
_checkpointer.request(self)
|
||||
|
||||
def _cadence_checkpoint(self) -> None:
|
||||
"""One PASSIVE checkpoint through the writer, and the WAL alert after it."""
|
||||
now = time.monotonic()
|
||||
try:
|
||||
busy, log_frames, checkpointed = self._checkpoint_from_maintenance("PASSIVE")
|
||||
except sqlite3.Error:
|
||||
logger.warning("main_database_checkpoint_failed file=%s", self.path.name, exc_info=True)
|
||||
return
|
||||
wal_bytes = self._wal_bytes()
|
||||
if wal_bytes >= MAIN_DATABASE_WAL_WARN_BYTES:
|
||||
# Frames the checkpoint could not copy back yet: what a reader is
|
||||
# holding up. The file size alone also counts space already
|
||||
# checkpointed and only waiting to be reused or truncated, which is
|
||||
# not the WAL failing to converge.
|
||||
pending_bytes = max(0, log_frames - checkpointed) * self._page_size
|
||||
if pending_bytes >= MAIN_DATABASE_WAL_WARN_BYTES:
|
||||
with self._state:
|
||||
should_warn = now - self._wal_warned_at >= MAIN_DATABASE_REPORT_SECONDS
|
||||
if should_warn:
|
||||
self._wal_warned_at = now
|
||||
if should_warn:
|
||||
logger.warning(
|
||||
"main_database_wal_high file=%s wal_bytes=%d busy=%d "
|
||||
"main_database_wal_high file=%s wal_bytes=%d pending_bytes=%d busy=%d "
|
||||
"log_frames=%d checkpointed=%d readers_in_use=%d",
|
||||
self.path.name,
|
||||
wal_bytes,
|
||||
pending_bytes,
|
||||
busy,
|
||||
log_frames,
|
||||
checkpointed,
|
||||
@@ -640,6 +790,8 @@ class MainDatabase:
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close every idle connection now; ones in use close when handed back."""
|
||||
# A checkpoint still waiting would open the writer again after this.
|
||||
_checkpointer.forget(self)
|
||||
with self._state:
|
||||
self._generation += 1
|
||||
self._identity = None
|
||||
@@ -784,6 +936,10 @@ class MainDatabase:
|
||||
try:
|
||||
if wal:
|
||||
enable_write_ahead_log(connection, wait_ms=busy_timeout_ms)
|
||||
# Only the writer restarts the WAL, so only the writer needs the cap.
|
||||
connection.execute(
|
||||
f"PRAGMA journal_size_limit = {int(MAIN_DATABASE_JOURNAL_SIZE_LIMIT_BYTES)}"
|
||||
)
|
||||
_seal(connection)
|
||||
if busy_timeout_ms != WRITE_LOCK_WAIT_MS:
|
||||
# Opened at the caller's wait; every checkout starts at the default.
|
||||
@@ -1027,6 +1183,18 @@ def main_database_transaction(
|
||||
yield connection
|
||||
|
||||
|
||||
def checkpoint_idle_main_databases() -> int:
|
||||
"""TRUNCATE the WAL of every database nobody is using; how many were emptied."""
|
||||
with _databases_lock:
|
||||
databases = list(_alive.values())
|
||||
emptied = 0
|
||||
for database in databases:
|
||||
result = database.checkpoint_if_idle()
|
||||
if result is not None and result[0] == 0:
|
||||
emptied += 1
|
||||
return emptied
|
||||
|
||||
|
||||
def close_main_databases() -> None:
|
||||
"""Close every connection this process holds; used when the Core stops."""
|
||||
with _databases_lock:
|
||||
@@ -1050,6 +1218,9 @@ async def report_main_database(seconds: float) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(seconds)
|
||||
log_main_database_stats("periodic")
|
||||
# The idle signal is this process's own checkouts: none held, none
|
||||
# waiting. Off the loop, since a checkpoint is disk work however short.
|
||||
await asyncio.to_thread(checkpoint_idle_main_databases)
|
||||
|
||||
|
||||
def main_database_stats() -> dict[str, float | int]:
|
||||
|
||||
@@ -126,15 +126,23 @@ def embedding_switch_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
|
||||
@router.post("/api/v1/embedding-switches", status_code=202, response_model=None)
|
||||
async def start_embedding_switch(value: EmbeddingSwitchStartRequest, request: Request) -> Any:
|
||||
try:
|
||||
job = await _service(request).start_switch(
|
||||
value.profile_id,
|
||||
expected_revision=value.expected_revision,
|
||||
plan_token=value.plan_token,
|
||||
requested_by=value.requested_by,
|
||||
)
|
||||
except EmbeddingSwitchError as error:
|
||||
return refusal(error.code, plan=error.plan)
|
||||
# One lifecycle lock orders mode/evolve/backfill and embedding
|
||||
# maintenance. Without it each endpoint could pass its own preflight
|
||||
# and then begin concurrently.
|
||||
async with request.app.state.memory_mode_lock:
|
||||
evolve = request.app.state.evolve_service
|
||||
backfill = request.app.state.memory_graph_backfill
|
||||
if evolve.running() or backfill.running:
|
||||
return refusal("memory_lifecycle_busy")
|
||||
try:
|
||||
job = await _service(request).start_switch(
|
||||
value.profile_id,
|
||||
expected_revision=value.expected_revision,
|
||||
plan_token=value.plan_token,
|
||||
requested_by=value.requested_by,
|
||||
)
|
||||
except EmbeddingSwitchError as error:
|
||||
return refusal(error.code, plan=error.plan)
|
||||
return job.model_dump(mode="json")
|
||||
|
||||
@router.get("/api/v1/embedding-switches/current")
|
||||
|
||||
@@ -14,7 +14,7 @@ from pathlib import Path
|
||||
from typing import Literal, cast
|
||||
|
||||
HY_MEMORY_DISTRIBUTION = "hy-memory"
|
||||
HY_MEMORY_VERSION = "2.0.12"
|
||||
HY_MEMORY_VERSION = "2.0.14"
|
||||
HY_MEMORY_NAMESPACE = "governed-v2"
|
||||
DiagnosticLogLevel = Literal["error", "warning", "info", "debug"]
|
||||
|
||||
|
||||
@@ -503,7 +503,7 @@ def _ensure_coding_schema(path: Path) -> None:
|
||||
CodingMemoryStore,
|
||||
)
|
||||
|
||||
# Relies on the pinned SDK's internals (hy-memory==2.0.12 is pinned exactly).
|
||||
# Relies on the pinned SDK's internals (hy-memory==2.0.14 is pinned exactly).
|
||||
store = CodingMemoryStore.__new__(CodingMemoryStore)
|
||||
store._db_path = str(path)
|
||||
store._conn = None
|
||||
|
||||
@@ -75,6 +75,9 @@ class HySdkMemoryRecord(BaseModel):
|
||||
# Epoch seconds. `updated_at` falls back to `created_at`.
|
||||
created_at: int | None = None
|
||||
updated_at: int | None = None
|
||||
# Actual scene=all ordering timestamp before the public fallback above.
|
||||
# Internal-only: callers keep the existing SdkMemoryRecord wire shape.
|
||||
sort_updated_at: int | None = Field(default=None, exclude=True, repr=False)
|
||||
|
||||
@classmethod
|
||||
def from_sdk(cls, raw: object) -> HySdkMemoryRecord:
|
||||
@@ -95,6 +98,10 @@ class HySdkMemoryRecord(BaseModel):
|
||||
if created_at is None:
|
||||
created_at = gmt_created
|
||||
updated_at = _epoch(raw.get("updated_at"))
|
||||
modified_at = _epoch(raw.get("gmt_modified"))
|
||||
sort_updated_at = (
|
||||
modified_at if modified_at is not None else updated_at
|
||||
)
|
||||
return cls(
|
||||
memory_id=str(raw["memory_id"]),
|
||||
content=str(raw["content"]),
|
||||
@@ -116,6 +123,7 @@ class HySdkMemoryRecord(BaseModel):
|
||||
gmt_created=gmt_created,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at if updated_at is not None else created_at,
|
||||
sort_updated_at=sort_updated_at,
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
raise HySdkReadError("hy_memory_record_shape_unknown") from error
|
||||
@@ -180,6 +188,7 @@ class HySdkMemoryRecord(BaseModel):
|
||||
solution=solution,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at if updated_at is not None else created_at,
|
||||
sort_updated_at=updated_at,
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
raise HySdkReadError("hy_memory_record_shape_unknown") from error
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from collections.abc import Iterable
|
||||
from contextlib import AbstractContextManager, nullcontext
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
@@ -18,13 +19,27 @@ from one_memory.imports.adapters import ScanPhase, SourceCursor
|
||||
#: the drift would only surface on a fresh database.
|
||||
#:
|
||||
#: This runs on SQLite today, so the SQLite rendering is used.
|
||||
from one_memory.memory_server.migrations import import_jobs_ddl
|
||||
from one_memory.memory_server.migrations import (
|
||||
import_job_lookup_indexes_ddl,
|
||||
import_jobs_ddl,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("one_memory.imports.jobs")
|
||||
|
||||
JOB_SCHEMA = import_jobs_ddl("sqlite")
|
||||
#: Migration 63, applied by the store as well so a running home gets it.
|
||||
JOB_LOOKUP_INDEXES = import_job_lookup_indexes_ddl("sqlite")
|
||||
MAX_LIVE_BACKLOG_BYTES = 256 * 1024
|
||||
|
||||
#: Finished live syncs kept per (user, source). Live sync opens a job for every
|
||||
#: change it imports, and nothing removed them: hundreds a day on an active
|
||||
#: machine. The App shows the latest job per source and the active ones; twenty is
|
||||
#: that plus enough recent history to read a failure back.
|
||||
SYNC_JOBS_KEPT_PER_SOURCE = 20
|
||||
#: Rows one pruning transaction may delete, so it holds the single writer for
|
||||
#: a few milliseconds at most however large the table grew.
|
||||
PRUNE_BATCH_ROWS = 200
|
||||
|
||||
|
||||
class ImportStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
@@ -36,6 +51,12 @@ class ImportStatus(StrEnum):
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
#: States no job ever leaves (`ImportJobStore._TRANSITIONS`). Failed and
|
||||
#: interrupted are not among them: both can be resumed, and both still hold the
|
||||
#: source's one active slot.
|
||||
FINISHED_STATUSES = (ImportStatus.COMPLETED, ImportStatus.CANCELLED)
|
||||
|
||||
|
||||
class ImportJob(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
@@ -272,6 +293,9 @@ class ImportJobStore:
|
||||
WHERE status IN ('pending', 'running', 'paused', 'interrupted', 'failed')
|
||||
"""
|
||||
)
|
||||
for statement in JOB_LOOKUP_INDEXES.split(";"):
|
||||
if statement.strip():
|
||||
connection.execute(statement)
|
||||
|
||||
def create(
|
||||
self,
|
||||
@@ -334,8 +358,8 @@ class ImportJobStore:
|
||||
read again.
|
||||
"""
|
||||
opened: list[ImportJob] = []
|
||||
for job in self.list_jobs(status=ImportStatus.FAILED):
|
||||
if job.operation != "history" or job.stored != 0:
|
||||
for job in self.find_jobs(operation="history", statuses=(ImportStatus.FAILED,)):
|
||||
if job.stored != 0:
|
||||
continue
|
||||
reasons = {issue.reason for issue in self.list_issues(job.job_id)}
|
||||
if not reasons or not reasons <= self._UNPUBLISHED_SNAPSHOT_FAILURES:
|
||||
@@ -799,16 +823,172 @@ class ImportJobStore:
|
||||
return self.require(job_id)
|
||||
|
||||
def list_jobs(self, *, status: ImportStatus | None = None) -> list[ImportJob]:
|
||||
"""Every job, oldest first. Hot paths ask `find_jobs` a narrower question."""
|
||||
return self.find_jobs(statuses=(status,) if status is not None else None)
|
||||
|
||||
def find_jobs(
|
||||
self,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
source_id: str | None = None,
|
||||
operation: Literal["history", "sync"] | None = None,
|
||||
statuses: Iterable[ImportStatus] | None = None,
|
||||
newest_first: bool = False,
|
||||
limit: int | None = None,
|
||||
) -> list[ImportJob]:
|
||||
"""The jobs matching every given filter, oldest first unless asked otherwise.
|
||||
|
||||
The filters are SQL, served by `import_jobs_lane` / `import_jobs_status`
|
||||
(migration 63). Reading the whole table and filtering here was what
|
||||
three bulk threads and three request threads were all inside, for tens
|
||||
of seconds, while the loop waited on them.
|
||||
"""
|
||||
clauses: list[str] = []
|
||||
parameters: list[str | int] = []
|
||||
if user_id is not None:
|
||||
clauses.append("user_id = ?")
|
||||
parameters.append(user_id)
|
||||
if source_id is not None:
|
||||
clauses.append("source_id = ?")
|
||||
parameters.append(source_id)
|
||||
if operation is not None:
|
||||
clauses.append("operation = ?")
|
||||
parameters.append(operation)
|
||||
if statuses is not None:
|
||||
wanted = tuple(dict.fromkeys(ImportStatus(status) for status in statuses))
|
||||
if not wanted:
|
||||
return []
|
||||
clauses.append(f"status IN ({', '.join('?' for _ in wanted)})")
|
||||
parameters.extend(wanted)
|
||||
query = "SELECT * FROM import_jobs"
|
||||
parameters: tuple[str, ...] = ()
|
||||
if status is not None:
|
||||
query += " WHERE status = ?"
|
||||
parameters = (status,)
|
||||
query += " ORDER BY created_at, job_id"
|
||||
if clauses:
|
||||
query += " WHERE " + " AND ".join(clauses)
|
||||
query += (
|
||||
" ORDER BY created_at DESC, job_id DESC"
|
||||
if newest_first
|
||||
else " ORDER BY created_at, job_id"
|
||||
)
|
||||
if limit is not None:
|
||||
if limit < 1:
|
||||
raise ValueError("limit must be positive")
|
||||
query += " LIMIT ?"
|
||||
parameters.append(limit)
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(query, parameters).fetchall()
|
||||
return [self._to_job(row) for row in rows]
|
||||
|
||||
def latest_job(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
source_id: str,
|
||||
operation: Literal["history", "sync"] | None = None,
|
||||
statuses: Iterable[ImportStatus] | None = None,
|
||||
) -> ImportJob | None:
|
||||
jobs = self.find_jobs(
|
||||
user_id=user_id,
|
||||
source_id=source_id,
|
||||
operation=operation,
|
||||
statuses=statuses,
|
||||
newest_first=True,
|
||||
limit=1,
|
||||
)
|
||||
return jobs[0] if jobs else None
|
||||
|
||||
def prune_finished_sync_jobs(
|
||||
self,
|
||||
*,
|
||||
keep: int = SYNC_JOBS_KEPT_PER_SOURCE,
|
||||
max_rows: int = PRUNE_BATCH_ROWS,
|
||||
) -> int:
|
||||
"""Delete finished live-sync jobs beyond the newest `keep` of each source.
|
||||
|
||||
One bounded transaction: at most `max_rows` jobs, with their issues and
|
||||
skip counts. Returns how many jobs went; fewer than `max_rows` means
|
||||
nothing is left to prune, so calling again until then finishes the job.
|
||||
|
||||
Only `sync` jobs in a state nothing leaves (completed, cancelled) are
|
||||
ever deleted. Failed and interrupted jobs hold the source's active slot
|
||||
and can be resumed; history jobs are named by connection receipts, by
|
||||
the onboarding gate and by the first-import review, and there are a
|
||||
handful per source, so they are all kept. Cursors live in their own
|
||||
tables keyed by file, not by job, so a pruned job takes no position
|
||||
with it. The newest job of every source is always among those kept,
|
||||
which is the one the App and live sync health name.
|
||||
"""
|
||||
if keep < 1:
|
||||
raise ValueError("keep must be positive")
|
||||
if max_rows < 1:
|
||||
raise ValueError("max_rows must be positive")
|
||||
finished = tuple(FINISHED_STATUSES)
|
||||
placeholders = ", ".join("?" for _ in finished)
|
||||
deleted = 0
|
||||
with self._connect(writing=True) as connection:
|
||||
lanes = connection.execute(
|
||||
f"""
|
||||
SELECT user_id, source_id, COUNT(*) AS finished
|
||||
FROM import_jobs
|
||||
WHERE operation = 'sync' AND status IN ({placeholders})
|
||||
GROUP BY user_id, source_id
|
||||
HAVING COUNT(*) > ?
|
||||
""",
|
||||
(*finished, keep),
|
||||
).fetchall()
|
||||
for lane in lanes:
|
||||
if deleted >= max_rows:
|
||||
break
|
||||
# The oldest job still kept: everything strictly older goes.
|
||||
boundary = connection.execute(
|
||||
f"""
|
||||
SELECT created_at, job_id FROM import_jobs
|
||||
WHERE user_id = ? AND source_id = ? AND operation = 'sync'
|
||||
AND status IN ({placeholders})
|
||||
ORDER BY created_at DESC, job_id DESC
|
||||
LIMIT 1 OFFSET ?
|
||||
""",
|
||||
(lane["user_id"], lane["source_id"], *finished, keep - 1),
|
||||
).fetchone()
|
||||
if boundary is None:
|
||||
continue
|
||||
victims = [
|
||||
str(row["job_id"])
|
||||
for row in connection.execute(
|
||||
f"""
|
||||
SELECT job_id FROM import_jobs
|
||||
WHERE user_id = ? AND source_id = ? AND operation = 'sync'
|
||||
AND status IN ({placeholders})
|
||||
AND (created_at < ? OR (created_at = ? AND job_id < ?))
|
||||
ORDER BY created_at, job_id
|
||||
LIMIT ?
|
||||
""",
|
||||
(
|
||||
lane["user_id"],
|
||||
lane["source_id"],
|
||||
*finished,
|
||||
boundary["created_at"],
|
||||
boundary["created_at"],
|
||||
boundary["job_id"],
|
||||
max_rows - deleted,
|
||||
),
|
||||
)
|
||||
]
|
||||
if not victims:
|
||||
continue
|
||||
marks = ", ".join("?" for _ in victims)
|
||||
# Foreign keys cascade these too; deleting them by name keeps
|
||||
# the outcome the same on a connection that has them off.
|
||||
connection.execute(f"DELETE FROM import_issues WHERE job_id IN ({marks})", victims)
|
||||
connection.execute(
|
||||
f"DELETE FROM import_skip_reason_counts WHERE job_id IN ({marks})", victims
|
||||
)
|
||||
removed = connection.execute(
|
||||
f"DELETE FROM import_jobs WHERE job_id IN ({marks}) "
|
||||
f"AND operation = 'sync' AND status IN ({placeholders})",
|
||||
(*victims, *finished),
|
||||
)
|
||||
deleted += removed.rowcount
|
||||
return deleted
|
||||
|
||||
def get_source_cursors(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -28,7 +28,7 @@ from one_memory.imports.adapters import (
|
||||
)
|
||||
from one_memory.imports.jobs import ImportJob, ImportJobStore, ImportStatus
|
||||
from one_memory.imports.supervisor import ImportSupervisor
|
||||
from one_memory.user_scope import DEFAULT_USER_ID, lane_key, split_lane
|
||||
from one_memory.user_scope import DEFAULT_USER_ID, split_lane
|
||||
|
||||
#: How long an automatic retry will wait for a wedged worker to notice cancel.
|
||||
ABANDON_TIMEOUT_SECONDS = 5.0
|
||||
@@ -249,13 +249,15 @@ class PollingLiveSyncSupervisor:
|
||||
),
|
||||
)
|
||||
return None
|
||||
source_jobs = await in_the_background(self.job_store.list_jobs)
|
||||
# This lane's unfinished jobs only: the newest unfinished sync, and
|
||||
# whatever history shares its cursor. Asked in SQL; listing every
|
||||
# job ever run and filtering here was the poll's biggest read.
|
||||
source_jobs = await in_the_background(self._lane_jobs, source_id)
|
||||
active_sync = next(
|
||||
(
|
||||
job
|
||||
for job in reversed(source_jobs)
|
||||
if lane_key(job.user_id, job.source_id) == source_id
|
||||
and job.operation == "sync"
|
||||
if job.operation == "sync"
|
||||
and job.status
|
||||
in {
|
||||
ImportStatus.PENDING,
|
||||
@@ -275,7 +277,9 @@ class PollingLiveSyncSupervisor:
|
||||
active_sync.job_id
|
||||
):
|
||||
attempted_at = datetime.now(UTC)
|
||||
self.import_supervisor.enqueue(active_sync.job_id)
|
||||
await self.import_supervisor.enqueue_off_loop(
|
||||
active_sync.job_id, job=active_sync
|
||||
)
|
||||
if not wait_for_completion:
|
||||
self._observe_sync(source_id, attempted_at, active_sync.job_id)
|
||||
return active_sync
|
||||
@@ -295,22 +299,20 @@ class PollingLiveSyncSupervisor:
|
||||
# by a process restart between yield and claim. Enqueue is
|
||||
# the operation that actually creates the task.
|
||||
attempted_at = datetime.now(UTC)
|
||||
self.import_supervisor.resume(active_sync.job_id)
|
||||
self.import_supervisor.enqueue(active_sync.job_id)
|
||||
resumed = await self.import_supervisor.resume_off_loop(active_sync.job_id)
|
||||
await self.import_supervisor.enqueue_off_loop(active_sync.job_id, job=resumed)
|
||||
completed = await self.import_supervisor.wait(active_sync.job_id)
|
||||
self._record_outcome(source_id, attempted_at, completed)
|
||||
return completed
|
||||
competing = [
|
||||
job
|
||||
for job in source_jobs
|
||||
if lane_key(job.user_id, job.source_id) == source_id
|
||||
and job.operation != "sync"
|
||||
if job.operation != "sync"
|
||||
and job.status
|
||||
in {ImportStatus.PENDING, ImportStatus.RUNNING, ImportStatus.INTERRUPTED}
|
||||
]
|
||||
bounded_history_incomplete = any(
|
||||
lane_key(job.user_id, job.source_id) == source_id
|
||||
and job.operation == "history"
|
||||
job.operation == "history"
|
||||
and job.history_after is not None
|
||||
and job.status is not ImportStatus.COMPLETED
|
||||
for job in source_jobs
|
||||
@@ -354,6 +356,38 @@ class PollingLiveSyncSupervisor:
|
||||
# automatically after the realtime slice completes.
|
||||
return await self._run_sync(source_id, wait_for_completion=wait_for_completion)
|
||||
|
||||
def _lane_jobs(self, source_id: str) -> list[ImportJob]:
|
||||
"""The lane's jobs `_sync` decides on, oldest first.
|
||||
|
||||
Everything not completed, of either operation: an unfinished sync to
|
||||
pick up, a history that competes for the cursor, and a bounded history
|
||||
that has not completed yet (which includes a cancelled one). Finished
|
||||
syncs, the rows that grow without end, are never read.
|
||||
"""
|
||||
user_id, bare_source_id = split_lane(source_id)
|
||||
unfinished = tuple(
|
||||
status for status in ImportStatus if status is not ImportStatus.COMPLETED
|
||||
)
|
||||
return [
|
||||
*self.job_store.find_jobs(
|
||||
user_id=user_id,
|
||||
source_id=bare_source_id,
|
||||
operation="history",
|
||||
statuses=unfinished,
|
||||
),
|
||||
*self.job_store.find_jobs(
|
||||
user_id=user_id,
|
||||
source_id=bare_source_id,
|
||||
operation="sync",
|
||||
statuses=(
|
||||
ImportStatus.PENDING,
|
||||
ImportStatus.RUNNING,
|
||||
ImportStatus.PAUSED,
|
||||
ImportStatus.INTERRUPTED,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
async def _has_work(self, source_id: str) -> bool:
|
||||
"""Ask the source whether a scan would write anything, before taking a job out.
|
||||
|
||||
@@ -452,7 +486,7 @@ class PollingLiveSyncSupervisor:
|
||||
),
|
||||
)
|
||||
self._active_jobs[source_id] = job.job_id
|
||||
self.import_supervisor.enqueue(job.job_id)
|
||||
await self.import_supervisor.enqueue_off_loop(job.job_id, job=job)
|
||||
if not wait_for_completion:
|
||||
self._observe_sync(source_id, attempted_at, job.job_id)
|
||||
return job
|
||||
|
||||
@@ -23,6 +23,7 @@ from pathlib import Path
|
||||
|
||||
from watchfiles import awatch
|
||||
|
||||
from one_memory.background_work import in_the_background
|
||||
from one_memory.diagnostic_log import diagnostic_event
|
||||
from one_memory.imports.jobs import ImportStatus
|
||||
from one_memory.imports.live_sync import PollingLiveSyncSupervisor
|
||||
@@ -90,7 +91,8 @@ class FileWatchLiveSync:
|
||||
# turn that caused setup. The watcher cannot observe a change
|
||||
# from before it existed, so immediately run the same bounded
|
||||
# trigger once instead of waiting for the 60-second backstop.
|
||||
self._queue_changes(set(self.roots.values()))
|
||||
# The roots are this watcher's own sources: nothing to resolve.
|
||||
self._queue_sources(tuple(self.roots), count=len(self.roots))
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
task = self._task
|
||||
@@ -139,28 +141,54 @@ class FileWatchLiveSync:
|
||||
observed_at: datetime | None = None,
|
||||
) -> tuple[str, ...]:
|
||||
"""Queue affected sources and wait for their coalesced work to settle."""
|
||||
workers = self._queue_changes(changed, observed_at=observed_at)
|
||||
workers = await self._queue_changes(changed, observed_at=observed_at)
|
||||
await asyncio.gather(*(asyncio.shield(worker) for worker in workers.values()))
|
||||
return tuple(workers)
|
||||
|
||||
def _queue_changes(
|
||||
def sources_for(self, changed: set[Path]) -> tuple[str, ...]:
|
||||
"""The sources owning `changed`, in path order. Resolves every path."""
|
||||
return tuple(
|
||||
dict.fromkeys(
|
||||
source
|
||||
for source in (self.source_for(path) for path in sorted(changed))
|
||||
if source is not None
|
||||
)
|
||||
)
|
||||
|
||||
async def _queue_changes(
|
||||
self, changed: set[Path], *, observed_at: datetime | None = None
|
||||
) -> dict[str, asyncio.Task[None]]:
|
||||
"""Attribute a batch of changed paths to sources and queue them.
|
||||
|
||||
Attributing resolves each path, a `stat` per component, and a busy
|
||||
host reports hundreds of paths at once. On the loop that was the main
|
||||
thread inside `source_for` while nothing else was answered, so the
|
||||
resolving happens off it and only the queueing here.
|
||||
"""
|
||||
if self._stop.is_set():
|
||||
return {}
|
||||
observed = observed_at or datetime.now(UTC)
|
||||
sources = await in_the_background(self.sources_for, changed)
|
||||
return self._queue_sources(sources, count=len(changed), observed_at=observed)
|
||||
|
||||
def _queue_sources(
|
||||
self,
|
||||
sources: tuple[str, ...],
|
||||
*,
|
||||
count: int,
|
||||
observed_at: datetime | None = None,
|
||||
) -> dict[str, asyncio.Task[None]]:
|
||||
if self._stop.is_set():
|
||||
return {}
|
||||
observed = observed_at or datetime.now(UTC)
|
||||
workers: dict[str, asyncio.Task[None]] = {}
|
||||
for source_id in dict.fromkeys(
|
||||
source
|
||||
for source in (self.source_for(path) for path in sorted(changed))
|
||||
if source is not None
|
||||
):
|
||||
for source_id in sources:
|
||||
diagnostic_event(
|
||||
level="debug",
|
||||
component="import.live_watch",
|
||||
event="live_sync.hook_received",
|
||||
outcome="queued",
|
||||
details={"source_id": source_id, "count": len(changed)},
|
||||
details={"source_id": source_id, "count": count},
|
||||
)
|
||||
self._pending[source_id] = max(self._pending.get(source_id, observed), observed)
|
||||
worker = self._workers.get(source_id)
|
||||
@@ -239,7 +267,7 @@ class FileWatchLiveSync:
|
||||
stop_event=self._stop,
|
||||
ignore_permission_denied=True,
|
||||
):
|
||||
self._queue_changes({Path(path) for _change, path in changes})
|
||||
await self._queue_changes({Path(path) for _change, path in changes})
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
|
||||
@@ -10,7 +10,10 @@ from pydantic import JsonValue
|
||||
from one_memory.input_sanitization import sanitize_model_input
|
||||
|
||||
_REMINDER_TAGS = r"additional_data|system[-_]reminder|working_memory_reminder"
|
||||
_CONTEXT_TAGS = r"user_info|rules|agent_transcripts|system[-_]notification|pipeline_log"
|
||||
_CONTEXT_TAGS = (
|
||||
r"user_info|rules|agent_transcripts|attached_files|image_local_path|"
|
||||
r"system[-_]notification|pipeline_log"
|
||||
)
|
||||
_TAG_ATTRIBUTES = r"""(?:\s+[\w:-]+\s*=\s*(?:"[^"<>]*"|'[^'<>]*'))*\s*"""
|
||||
_HOST_PREFIX = re.compile(rf"<(?P<tag>{_REMINDER_TAGS}|{_CONTEXT_TAGS}){_TAG_ATTRIBUTES}>")
|
||||
_REMINDER_START = re.compile(rf"<(?P<tag>{_REMINDER_TAGS}){_TAG_ATTRIBUTES}>")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from contextlib import suppress
|
||||
from functools import partial
|
||||
from time import monotonic
|
||||
from typing import Literal
|
||||
|
||||
@@ -82,6 +83,10 @@ class ImportSupervisor:
|
||||
)
|
||||
self._tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self._drains: dict[str, asyncio.Task[None]] = {}
|
||||
#: Pause and cancel settle a stopped job with a read and a transition.
|
||||
#: Made on the loop, two of them could not interleave; made off it,
|
||||
#: they are serialized here so they still land in the order they woke.
|
||||
self._settling = asyncio.Lock()
|
||||
|
||||
def register_adapter(self, adapter: SourceAdapter, *, user_id: str = "local") -> None:
|
||||
"""Adapters are keyed by lane: the source id for `local`, `source@user`
|
||||
@@ -116,11 +121,44 @@ class ImportSupervisor:
|
||||
raise UnknownSourceError(lane)
|
||||
if self.source_enabled is not None and not self.source_enabled(lane):
|
||||
return job
|
||||
existing = self._tasks.get(job_id)
|
||||
self._start(job)
|
||||
return job
|
||||
|
||||
async def enqueue_off_loop(self, job_id: str, *, job: ImportJob | None = None) -> ImportJob:
|
||||
"""`enqueue`, with its job read and its grant check made off the loop.
|
||||
|
||||
Both are database reads, and the grant check resolves the source root
|
||||
on disk. Live sync enqueues from its poll, so on the loop they were
|
||||
the main thread waiting for a reader behind an import peak. `job` is
|
||||
the row the caller has just read, when it has one.
|
||||
"""
|
||||
if job_id in self._drains:
|
||||
raise InvalidJobTransitionError("import_cleanup_in_progress")
|
||||
|
||||
def admit() -> tuple[ImportJob, bool]:
|
||||
current = job if job is not None and job.job_id == job_id else None
|
||||
if current is None:
|
||||
current = self.job_store.require(job_id)
|
||||
lane = self.lane_of(current)
|
||||
return current, self.source_enabled is None or self.source_enabled(lane)
|
||||
|
||||
current, enabled = await in_the_background(admit)
|
||||
lane = self.lane_of(current)
|
||||
if lane not in self.adapters:
|
||||
raise UnknownSourceError(lane)
|
||||
if job_id in self._drains:
|
||||
raise InvalidJobTransitionError("import_cleanup_in_progress")
|
||||
if enabled:
|
||||
self._start(current)
|
||||
return current
|
||||
|
||||
def _start(self, job: ImportJob) -> None:
|
||||
lane = self.lane_of(job)
|
||||
existing = self._tasks.get(job.job_id)
|
||||
if existing is None or existing.done():
|
||||
self._tasks[job_id] = asyncio.create_task(
|
||||
self._tasks[job.job_id] = asyncio.create_task(
|
||||
self._execute(
|
||||
job_id,
|
||||
job.job_id,
|
||||
source_id=lane,
|
||||
operation=job.operation,
|
||||
)
|
||||
@@ -130,10 +168,24 @@ class ImportSupervisor:
|
||||
component="import.supervisor",
|
||||
event="import.queued",
|
||||
outcome="queued",
|
||||
job_id=job_id,
|
||||
job_id=job.job_id,
|
||||
details={"source_id": job.source_id, "mode": job.operation},
|
||||
)
|
||||
return job
|
||||
|
||||
async def resume_interrupted_off_loop(self) -> None:
|
||||
"""`resume_interrupted` for the running Core: every read and write off the loop."""
|
||||
for status in (ImportStatus.INTERRUPTED, ImportStatus.PENDING):
|
||||
jobs = await in_the_background(partial(self.job_store.find_jobs, statuses=(status,)))
|
||||
for job in jobs:
|
||||
if self.lane_of(job) not in self.adapters:
|
||||
continue
|
||||
if job.status is not ImportStatus.PENDING:
|
||||
if self.source_enabled is not None and not await in_the_background(
|
||||
self.source_enabled, self.lane_of(job)
|
||||
):
|
||||
continue
|
||||
job = await in_the_background(self.job_store.queue_for_resume, job.job_id)
|
||||
await self.enqueue_off_loop(job.job_id, job=job)
|
||||
|
||||
def resume_interrupted(self) -> None:
|
||||
# A process restart must not place recoverable work behind every newly
|
||||
@@ -166,40 +218,64 @@ class ImportSupervisor:
|
||||
self.enqueue(job_id)
|
||||
return queued
|
||||
|
||||
async def resume_off_loop(self, job_id: str) -> ImportJob:
|
||||
"""`resume`, with its reads and its write made off the event loop."""
|
||||
if job_id in self._drains:
|
||||
raise InvalidJobTransitionError("import_cleanup_in_progress")
|
||||
queued = await in_the_background(self._queue_resumable, job_id)
|
||||
if queued.status is not ImportStatus.PENDING:
|
||||
return queued
|
||||
return await self.enqueue_off_loop(job_id, job=queued)
|
||||
|
||||
def _queue_resumable(self, job_id: str) -> ImportJob:
|
||||
job = self.job_store.require(job_id)
|
||||
if job.status not in {
|
||||
ImportStatus.PENDING,
|
||||
ImportStatus.PAUSED,
|
||||
ImportStatus.INTERRUPTED,
|
||||
ImportStatus.FAILED,
|
||||
}:
|
||||
return job
|
||||
return self.job_store.queue_for_resume(job_id)
|
||||
|
||||
async def wait(self, job_id: str) -> ImportJob:
|
||||
task = self._tasks.get(job_id)
|
||||
if task is not None:
|
||||
await asyncio.shield(task)
|
||||
return self.job_store.require(job_id)
|
||||
return await in_the_background(self.job_store.require, job_id)
|
||||
|
||||
async def pause(self, job_id: str) -> ImportJob:
|
||||
job = self.job_store.require(job_id)
|
||||
job = await in_the_background(self.job_store.require, job_id)
|
||||
if job.status in {ImportStatus.PAUSED, ImportStatus.INTERRUPTED}:
|
||||
return job
|
||||
await self._cancel_task(job_id)
|
||||
current = self.job_store.require(job_id)
|
||||
if current.status is ImportStatus.COMPLETED:
|
||||
return current
|
||||
return self.job_store.transition(job_id, ImportStatus.PAUSED)
|
||||
async with self._settling:
|
||||
return await in_the_background(self._settle_stopped, job_id, ImportStatus.PAUSED)
|
||||
|
||||
async def cancel(self, job_id: str) -> ImportJob:
|
||||
job = self.job_store.require(job_id)
|
||||
job = await in_the_background(self.job_store.require, job_id)
|
||||
if job.status is ImportStatus.CANCELLED:
|
||||
return job
|
||||
await self._cancel_task(job_id)
|
||||
async with self._settling:
|
||||
return await in_the_background(self._settle_stopped, job_id, ImportStatus.CANCELLED)
|
||||
|
||||
def _settle_stopped(self, job_id: str, target: ImportStatus) -> ImportJob:
|
||||
"""A stopped runner's job moves to `target`, unless it completed first."""
|
||||
current = self.job_store.require(job_id)
|
||||
if current.status is ImportStatus.COMPLETED:
|
||||
return current
|
||||
return self.job_store.transition(job_id, ImportStatus.CANCELLED)
|
||||
return self.job_store.transition(job_id, target)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
running = [
|
||||
job
|
||||
for job in self.job_store.list_jobs(status=ImportStatus.RUNNING)
|
||||
if job.job_id in self._tasks
|
||||
]
|
||||
for job in running:
|
||||
self.job_store.interrupt_running(job.job_id)
|
||||
mine = frozenset(self._tasks)
|
||||
|
||||
def interrupt_mine() -> None:
|
||||
for job in self.job_store.find_jobs(statuses=(ImportStatus.RUNNING,)):
|
||||
if job.job_id in mine:
|
||||
self.job_store.interrupt_running(job.job_id)
|
||||
|
||||
await in_the_background(interrupt_mine)
|
||||
await asyncio.gather(
|
||||
*(self._cancel_task(job_id) for job_id in tuple(self._tasks)),
|
||||
)
|
||||
@@ -355,9 +431,7 @@ class ImportSupervisor:
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(task), timeout)
|
||||
except TimeoutError:
|
||||
current = self.job_store.require(job_id)
|
||||
if current.status is ImportStatus.RUNNING:
|
||||
self.job_store.interrupt_running(job_id)
|
||||
await in_the_background(self._interrupt_if_running, job_id)
|
||||
return False
|
||||
except asyncio.CancelledError:
|
||||
# The worker noticed cancel and exited. A cancel of this
|
||||
@@ -365,10 +439,16 @@ class ImportSupervisor:
|
||||
caller = asyncio.current_task()
|
||||
if caller is not None and caller.cancelling():
|
||||
raise
|
||||
# A read and a write on the single writer: off the loop. Live sync
|
||||
# calls this from its poll, and on the loop it was the main thread
|
||||
# waiting its turn for the writer behind an import peak.
|
||||
await in_the_background(self._interrupt_if_running, job_id)
|
||||
return True
|
||||
|
||||
def _interrupt_if_running(self, job_id: str) -> None:
|
||||
current = self.job_store.require(job_id)
|
||||
if current.status is ImportStatus.RUNNING:
|
||||
self.job_store.interrupt_running(job_id)
|
||||
return True
|
||||
|
||||
async def _cancel_task(self, job_id: str) -> None:
|
||||
drain = self._drains.get(job_id)
|
||||
|
||||
@@ -34,6 +34,7 @@ from one_memory.memory_governance import MemoryGovernanceStore, MemoryStatus
|
||||
from one_memory.memory_server.durable_queue import DurableWriteQueue
|
||||
from one_memory.memory_server.unified_entry import UnifiedWriteIntake
|
||||
from one_memory.memory_tasks import MemoryTaskStatus, MemoryTaskStore
|
||||
from one_memory.model_backoff import next_retry_at
|
||||
from one_memory.secrets import SecretStoreUnavailableError
|
||||
from one_memory.user_scope import DEFAULT_USER_ID, USER_ID_PATTERN, UserId
|
||||
|
||||
@@ -77,6 +78,12 @@ class MemoryAddReceipt(BaseModel):
|
||||
task_id: str | None = None
|
||||
memory_ids: list[str] = Field(default_factory=list)
|
||||
error: str | None = None
|
||||
#: Why a note still being worked on last failed (`model_auth_failed`, ...),
|
||||
#: or why a failed one failed. A provider failure keeps it `pending`.
|
||||
error_code: str | None = None
|
||||
#: When a pending note is next tried: its own retry wait, or the model
|
||||
#: provider's pause while the provider is failing.
|
||||
next_retry_at: str | None = None
|
||||
|
||||
|
||||
class LegacyMemoryAdditionReader:
|
||||
@@ -258,6 +265,8 @@ class MemoryAdditionStore:
|
||||
durable_state = durable.state if durable is not None else "failed"
|
||||
mapped: MemoryAddState = {
|
||||
"pending": "pending",
|
||||
# Re-run automatically within seconds (`recover_unknown`).
|
||||
"unknown": "pending",
|
||||
"running": "running",
|
||||
"waiting_for_model": "waiting_for_model",
|
||||
"waiting_for_permission": "waiting_for_permission",
|
||||
@@ -272,6 +281,12 @@ class MemoryAdditionStore:
|
||||
state=mapped,
|
||||
memory_ids=(list(durable.sdk_memory_ids) if durable is not None else []),
|
||||
error=durable.detail if durable is not None else "write_missing",
|
||||
error_code=(
|
||||
durable.error_code
|
||||
if durable is not None and durable.state != "completed"
|
||||
else None
|
||||
),
|
||||
next_retry_at=durable.retry_at if durable is not None else None,
|
||||
)
|
||||
if self.processor is None:
|
||||
raise RuntimeError("legacy_memory_add_processor_unavailable")
|
||||
@@ -402,10 +417,15 @@ def memory_add_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
||||
) -> MemoryAddReceipt:
|
||||
try:
|
||||
return store(request).receipt_for_request(request_id, user_id=user_id)
|
||||
receipt = store(request).receipt_for_request(request_id, user_id=user_id)
|
||||
except KeyError as error:
|
||||
raise HTTPException(404, "memory_add_not_found") from error
|
||||
except ValueError as error:
|
||||
raise HTTPException(409, str(error)) from error
|
||||
return receipt.model_copy(
|
||||
update={
|
||||
"next_retry_at": next_retry_at(request.app, receipt.state, receipt.next_retry_at)
|
||||
}
|
||||
)
|
||||
|
||||
return router
|
||||
@@ -465,12 +465,16 @@ class EvolveScheduler:
|
||||
service: EvolveService,
|
||||
schedule: Callable[[], EvolveSchedule],
|
||||
users_with_memories: Callable[[], Awaitable[Iterable[str]]],
|
||||
lifecycle_lock: asyncio.Lock | None = None,
|
||||
lifecycle_open: Callable[[], bool] = lambda: True,
|
||||
now: Callable[[], datetime] = lambda: datetime.now().astimezone(),
|
||||
check_seconds: float = SCHEDULE_CHECK_SECONDS,
|
||||
) -> None:
|
||||
self.service = service
|
||||
self.schedule = schedule
|
||||
self.users_with_memories = users_with_memories
|
||||
self.lifecycle_lock = lifecycle_lock
|
||||
self.lifecycle_open = lifecycle_open
|
||||
self._now = now
|
||||
self.check_seconds = check_seconds
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
@@ -492,7 +496,15 @@ class EvolveScheduler:
|
||||
if not evolve_due(now, schedule, last, anchor):
|
||||
continue
|
||||
try:
|
||||
receipt = self.service.start(user_id, trigger="scheduled")
|
||||
if self.lifecycle_lock is None:
|
||||
if not self.lifecycle_open():
|
||||
break
|
||||
receipt = self.service.start(user_id, trigger="scheduled")
|
||||
else:
|
||||
async with self.lifecycle_lock:
|
||||
if not self.lifecycle_open():
|
||||
break
|
||||
receipt = self.service.start(user_id, trigger="scheduled")
|
||||
except EvolveModeError:
|
||||
break
|
||||
await self.service.wait(receipt.run_id)
|
||||
@@ -533,10 +545,12 @@ class MemoryGraphBackfill:
|
||||
*,
|
||||
coordinator: EvolveCoordinator,
|
||||
state: EvolveStateStore,
|
||||
lifecycle_open: Callable[[], bool] = lambda: True,
|
||||
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
||||
) -> None:
|
||||
self.coordinator = coordinator
|
||||
self.state = state
|
||||
self.lifecycle_open = lifecycle_open
|
||||
self._now = now
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
|
||||
@@ -557,6 +571,35 @@ class MemoryGraphBackfill:
|
||||
self._task = asyncio.create_task(self._run(users), name="one-memory-graph-backfill")
|
||||
return state
|
||||
|
||||
def recover(self, user_ids: Iterable[str]) -> GraphBuildState:
|
||||
"""Resume an interrupted backfill without repeating completed users."""
|
||||
if self.running:
|
||||
return self.state.graph_build()
|
||||
saved = self.state.graph_build()
|
||||
if saved.state != "running":
|
||||
return saved
|
||||
users = tuple(dict.fromkeys((*saved.users.keys(), *user_ids)))
|
||||
pending = tuple(
|
||||
user_id
|
||||
for user_id in users
|
||||
if saved.users.get(user_id, GraphBuildUser()).state != "succeeded"
|
||||
)
|
||||
resumed = saved.model_copy(deep=True)
|
||||
for user_id in pending:
|
||||
resumed.users[user_id] = GraphBuildUser()
|
||||
if not pending:
|
||||
resumed.state = "succeeded"
|
||||
resumed.finished_at = self._now()
|
||||
self.state.set_graph_build(resumed)
|
||||
return resumed
|
||||
resumed.state = "running"
|
||||
resumed.finished_at = None
|
||||
self.state.set_graph_build(resumed)
|
||||
self._task = asyncio.create_task(
|
||||
self._run(pending), name="one-memory-graph-backfill-recovery"
|
||||
)
|
||||
return resumed
|
||||
|
||||
async def wait(self) -> None:
|
||||
if self._task is not None:
|
||||
await asyncio.shield(self._task)
|
||||
@@ -564,6 +607,8 @@ class MemoryGraphBackfill:
|
||||
async def _run(self, users: tuple[str, ...]) -> None:
|
||||
state = self.state.graph_build()
|
||||
for user_id in users:
|
||||
while not self.lifecycle_open():
|
||||
await asyncio.sleep(0.2)
|
||||
state.users[user_id] = GraphBuildUser(state="running")
|
||||
await asyncio.to_thread(self.state.set_graph_build, state.model_copy(deep=True))
|
||||
try:
|
||||
|
||||
@@ -153,6 +153,11 @@ class ConfigStore:
|
||||
with self._lock:
|
||||
return self._document.memory_mode
|
||||
|
||||
def memory_mode_explicitly_configured(self) -> bool:
|
||||
"""Whether a person chose the mode, including choosing the default."""
|
||||
with self._lock:
|
||||
return "memory_mode" in self._document.model_fields_set
|
||||
|
||||
def set_memory_mode(self, mode: MemoryMode) -> ConfigDocument:
|
||||
if mode not in MEMORY_MODES:
|
||||
raise ValueError("invalid_memory_mode")
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3163,6 +3163,137 @@ async def _060_memory_note_requests(cursor: Any) -> None:
|
||||
await cursor.execute(statement)
|
||||
|
||||
|
||||
def write_job_retries_ddl(dialect: str = "mysql") -> str:
|
||||
"""Per-job retry state: which SDK request id is next, and retries spent.
|
||||
|
||||
The SDK replays a request id's first outcome forever, so every re-run of
|
||||
a job needs an id of its own (`sdk_attempt`). `retries` counts automatic
|
||||
retries after failures of the job itself; provider failures spend none.
|
||||
"""
|
||||
if dialect == "sqlite":
|
||||
return """
|
||||
ALTER TABLE memory_write_jobs
|
||||
ADD COLUMN sdk_attempt INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE memory_write_jobs
|
||||
ADD COLUMN retries INTEGER NOT NULL DEFAULT 0;
|
||||
"""
|
||||
return """
|
||||
ALTER TABLE memory_write_jobs
|
||||
ADD COLUMN sdk_attempt INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE memory_write_jobs
|
||||
ADD COLUMN retries INT NOT NULL DEFAULT 0;
|
||||
"""
|
||||
|
||||
|
||||
async def _061_write_job_retries(cursor: Any) -> None:
|
||||
for statement in _split_statements(write_job_retries_ddl(_dialect(cursor))):
|
||||
await cursor.execute(statement)
|
||||
|
||||
|
||||
ONBOARDING_PROGRESS_MIGRATION_VERSION = 62
|
||||
|
||||
|
||||
def onboarding_progress_ddl(dialect: str = "mysql") -> str:
|
||||
"""Append resumable onboarding choices without rewriting migration 12."""
|
||||
if dialect == "sqlite":
|
||||
return """
|
||||
ALTER TABLE onboarding_state
|
||||
ADD COLUMN mode_intent TEXT NULL;
|
||||
ALTER TABLE onboarding_state
|
||||
ADD COLUMN setup_step TEXT NULL;
|
||||
ALTER TABLE onboarding_state
|
||||
ADD COLUMN model_profile_id TEXT NULL;
|
||||
"""
|
||||
return """
|
||||
ALTER TABLE onboarding_state
|
||||
ADD COLUMN mode_intent VARCHAR(16) NULL AFTER updated_at;
|
||||
ALTER TABLE onboarding_state
|
||||
ADD COLUMN setup_step VARCHAR(16) NULL AFTER mode_intent;
|
||||
ALTER TABLE onboarding_state
|
||||
ADD COLUMN model_profile_id VARCHAR(36) NULL AFTER setup_step;
|
||||
"""
|
||||
|
||||
|
||||
async def _062_onboarding_progress(cursor: Any) -> None:
|
||||
if _dialect(cursor) == "sqlite":
|
||||
existing = await cursor.columns("onboarding_state")
|
||||
statements = _split_statements(onboarding_progress_ddl("sqlite"))
|
||||
for column, statement in zip(
|
||||
("mode_intent", "setup_step", "model_profile_id"),
|
||||
statements,
|
||||
strict=True,
|
||||
):
|
||||
if column not in existing:
|
||||
await cursor.execute(statement)
|
||||
return
|
||||
for statement in _split_statements(onboarding_progress_ddl("mysql")):
|
||||
try:
|
||||
await cursor.execute(statement)
|
||||
except Exception as error: # noqa: BLE001 - MySQL driver error type varies
|
||||
if _is_duplicate_column(error):
|
||||
continue
|
||||
raise
|
||||
|
||||
|
||||
IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION = 63
|
||||
|
||||
|
||||
def import_job_lookup_indexes_ddl(dialect: str = "mysql") -> str:
|
||||
"""Index the per-source and per-status import job lookups.
|
||||
|
||||
Migration 7 gave SQLite `import_jobs` no index beyond its primary key (the
|
||||
one-active-job index is partial), so every "this source's jobs" or "the
|
||||
jobs in this state" question read and sorted the whole table. Live sync
|
||||
adds a row per change it imports and nothing removed them, so on a machine
|
||||
a few weeks old that table was thousands of rows read several times a poll.
|
||||
|
||||
`import_jobs_lane` serves the lane lookups (one user, one source, one
|
||||
operation, a status, newest first) and the pruning of finished syncs;
|
||||
`import_jobs_status` serves restart recovery and the status filters.
|
||||
MySQL's migration 7 already has a status index and no `operation` column,
|
||||
so it only gains the ordered source index.
|
||||
"""
|
||||
if dialect == "sqlite":
|
||||
return """
|
||||
CREATE INDEX IF NOT EXISTS import_jobs_lane
|
||||
ON import_jobs(user_id, source_id, operation, status, created_at);
|
||||
CREATE INDEX IF NOT EXISTS import_jobs_status
|
||||
ON import_jobs(status, created_at);
|
||||
"""
|
||||
return """
|
||||
CREATE INDEX idx_import_jobs_source_created
|
||||
ON import_jobs(user_id, source_id, created_at);
|
||||
"""
|
||||
|
||||
|
||||
def _is_duplicate_index(error: Exception) -> bool:
|
||||
"""MySQL's "index name already there" (1061): the index is in place."""
|
||||
args: tuple[object, ...] = getattr(error, "args", ())
|
||||
return bool(args) and args[0] == 1061
|
||||
|
||||
|
||||
async def _063_import_job_lookup_indexes(cursor: Any) -> None:
|
||||
dialect = _dialect(cursor)
|
||||
if dialect == "sqlite":
|
||||
# `operation` was added by the job store, not by migration 7, so a
|
||||
# database built from the catalog alone may not have it yet. Same
|
||||
# declaration as the store's own addition.
|
||||
if "operation" not in await cursor.columns("import_jobs"):
|
||||
await cursor.execute(
|
||||
"ALTER TABLE import_jobs ADD COLUMN operation TEXT NOT NULL DEFAULT 'history'"
|
||||
)
|
||||
for statement in _split_statements(import_job_lookup_indexes_ddl(dialect)):
|
||||
await cursor.execute(statement)
|
||||
return
|
||||
for statement in _split_statements(import_job_lookup_indexes_ddl(dialect)):
|
||||
try:
|
||||
await cursor.execute(statement)
|
||||
except Exception as error: # noqa: BLE001 - MySQL driver error type varies
|
||||
if _is_duplicate_index(error):
|
||||
continue
|
||||
raise
|
||||
|
||||
|
||||
def _split_statements(sql: str) -> list[str]:
|
||||
"""Split a multi-statement script, dropping blanks, PRAGMA and comments.
|
||||
|
||||
@@ -3462,4 +3593,16 @@ MIGRATIONS: tuple[Migration, ...] = (
|
||||
_060_memory_note_requests,
|
||||
("memory_note_requests",),
|
||||
),
|
||||
Migration(61, "write_job_retries", _061_write_job_retries),
|
||||
Migration(
|
||||
ONBOARDING_PROGRESS_MIGRATION_VERSION,
|
||||
"onboarding_progress",
|
||||
_062_onboarding_progress,
|
||||
("onboarding_state",),
|
||||
),
|
||||
Migration(
|
||||
IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION,
|
||||
"import_job_lookup_indexes",
|
||||
_063_import_job_lookup_indexes,
|
||||
),
|
||||
)
|
||||
@@ -57,6 +57,11 @@ class WriteRecord:
|
||||
detail: str = ""
|
||||
raw_ids: tuple[str, ...] = ()
|
||||
memory_ids: tuple[str, ...] = ()
|
||||
#: The stable code of the last failure of a job still being worked on
|
||||
#: (or of a failed one).
|
||||
error_code: str | None = None
|
||||
#: When a waiting job is next tried, if a retry wait holds it.
|
||||
next_retry_at: str | None = None
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
|
||||
@@ -44,6 +44,7 @@ from one_memory.memory_server.durable_queue import (
|
||||
)
|
||||
from one_memory.memory_server.registry import WriteRegistry, WriteState
|
||||
from one_memory.memory_server.submit import WriteConflictError
|
||||
from one_memory.model_backoff import next_retry_at
|
||||
from one_memory.user_scope import DEFAULT_USER_ID, USER_ID_PATTERN, UserId
|
||||
|
||||
#: Durable keys of another user's writes start with this. A `local` request
|
||||
@@ -72,6 +73,12 @@ class MemoryWriteReceipt(BaseModel):
|
||||
detail: str = ""
|
||||
raw_ids: tuple[str, ...] = ()
|
||||
memory_ids: tuple[str, ...] = ()
|
||||
#: Why a job still being worked on last failed (`model_auth_failed`, ...),
|
||||
#: or why a failed one failed. A provider failure keeps `state` queued.
|
||||
error_code: str | None = None
|
||||
#: When it is next tried: the job's own retry wait, or the model
|
||||
#: provider's pause (`model_backoff`) while the provider is failing.
|
||||
next_retry_at: str | None = None
|
||||
|
||||
|
||||
class OpenAIMessage(BaseModel):
|
||||
@@ -235,6 +242,8 @@ def sdk_router(dependencies: list[Any] | None = None) -> APIRouter:
|
||||
detail=record.detail,
|
||||
raw_ids=record.raw_ids,
|
||||
memory_ids=record.memory_ids,
|
||||
error_code=record.error_code,
|
||||
next_retry_at=next_retry_at(request.app, record.state, record.next_retry_at),
|
||||
)
|
||||
|
||||
return instance
|
||||
@@ -0,0 +1,330 @@
|
||||
"""One pause for every model request while the provider keeps failing (#245).
|
||||
|
||||
A broken model configuration (a revoked key, a wrong base URL, a model that
|
||||
does not exist, a provider that is down) used to fail each queued job on its
|
||||
own: the drain claimed the next job the moment the last one failed, so a
|
||||
backlog of hundreds of jobs turned into requests as fast as the provider could
|
||||
refuse them. The pause is global, not per job: a failure says something about
|
||||
the provider, not about the conversation that happened to hit it.
|
||||
|
||||
After the first failure no further model work is claimed for 5 seconds; each
|
||||
failed probe after that doubles the pause, up to 3 minutes. A pause admits one
|
||||
probe job at a time. The first success, or any change the user makes to the
|
||||
model configuration, ends it at once. Jobs not yet sent stay `pending`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BACKOFF_FIRST_SECONDS = 5.0
|
||||
BACKOFF_CAP_SECONDS = 180.0
|
||||
#: How long one probe may hold the pause open before another is admitted. A
|
||||
#: probe job that never reaches the provider (nothing to remember, a refusal
|
||||
#: before the SDK) reports nothing, and the queue must not wait on it forever.
|
||||
PROBE_HOLD_SECONDS = 30.0
|
||||
#: Requests already in flight when the pause began are refused without going
|
||||
#: out once this many failures in a row confirm it is not a one-off.
|
||||
REFUSE_IN_FLIGHT_AFTER = 2
|
||||
|
||||
#: Statuses that say the configuration or the provider is wrong, not the one
|
||||
#: request: authentication, a missing model or route, throttling, a timeout
|
||||
#: and the provider's own failures. A 400/413/422 is about the request's
|
||||
#: content and is left to that job.
|
||||
_PROVIDER_STATUSES = {401: "model_auth_failed", 403: "model_auth_failed", 404: "model_not_found"}
|
||||
|
||||
#: Every code a provider failure is recorded under: a job that failed with one
|
||||
#: of these waits for the provider instead of spending a retry.
|
||||
MODEL_FAILURE_CODES = frozenset(
|
||||
{
|
||||
"model_auth_failed",
|
||||
"model_not_found",
|
||||
"model_timeout",
|
||||
"model_rate_limited",
|
||||
"model_server_error",
|
||||
"model_unreachable",
|
||||
"model_transport_failed",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def backoff_seconds(level: int) -> float:
|
||||
"""The pause after the `level`-th failed attempt in a row (1-based)."""
|
||||
if level < 1:
|
||||
return 0.0
|
||||
return min(BACKOFF_FIRST_SECONDS * float(2 ** min(level - 1, 16)), BACKOFF_CAP_SECONDS)
|
||||
|
||||
|
||||
def failure_code(status: int) -> str | None:
|
||||
"""The stable code for a provider HTTP status, or None if it is no provider failure."""
|
||||
if status in _PROVIDER_STATUSES:
|
||||
return _PROVIDER_STATUSES[status]
|
||||
if status in {408, 504}:
|
||||
return "model_timeout"
|
||||
if status == 429:
|
||||
return "model_rate_limited"
|
||||
if status >= 500:
|
||||
return "model_server_error"
|
||||
return None
|
||||
|
||||
|
||||
def transport_failure_code(error: BaseException) -> str:
|
||||
if isinstance(error, httpx.TimeoutException):
|
||||
return "model_timeout"
|
||||
if isinstance(error, httpx.ConnectError | httpx.NetworkError):
|
||||
return "model_unreachable"
|
||||
return "model_transport_failed"
|
||||
|
||||
|
||||
def request_kind(request: httpx.Request) -> str:
|
||||
"""`embedding` for the embeddings route, `chat` for everything else."""
|
||||
return "embedding" if request.url.path.rstrip("/").endswith("/embeddings") else "chat"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelBackoffState:
|
||||
#: When the pause ends; None when no pause is in force.
|
||||
until: datetime | None
|
||||
#: Failed attempts in a row.
|
||||
failures: int
|
||||
#: The last failure's stable code (`model_auth_failed`, ...).
|
||||
last_error: str | None
|
||||
profile_id: str | None
|
||||
|
||||
|
||||
class ModelBackoffRefusal(httpx.TransportError):
|
||||
"""A request refused locally because the provider is being given a rest."""
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class ModelAttempt:
|
||||
"""One job's write, as the provider saw it.
|
||||
|
||||
`failure` is the code of the first provider failure (or local refusal)
|
||||
that happened while this write was in flight, None if there was none.
|
||||
"""
|
||||
|
||||
failure: str | None = None
|
||||
|
||||
|
||||
class ModelBackoff:
|
||||
"""Process-wide circuit breaker for the active model profile.
|
||||
|
||||
Thread-safe: the transport records from the SDK's own loop thread, the
|
||||
drain asks from its claim thread, and status reads from the event loop.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
wall_clock: Callable[[], datetime] = lambda: datetime.now(UTC),
|
||||
) -> None:
|
||||
self._clock = clock
|
||||
self._wall_clock = wall_clock
|
||||
self._lock = threading.Lock()
|
||||
#: Writes in flight (`attempt`); never cleared by a reset.
|
||||
self._attempts: set[ModelAttempt] = set()
|
||||
self._reset_locked()
|
||||
|
||||
def _reset_locked(self) -> None:
|
||||
#: Which kinds of request are failing (`chat`, `embedding`): a working
|
||||
#: embedding endpoint says nothing about a chat model that is gone.
|
||||
self._failing: set[str] = set()
|
||||
self._level = 0
|
||||
self._failures = 0
|
||||
self._until = 0.0
|
||||
self._probe_until = 0.0
|
||||
self._last_error: str | None = None
|
||||
self._profile_id: str | None = None
|
||||
|
||||
def reset(self, reason: str = "model_configuration_changed") -> None:
|
||||
"""Forget every failure: the user changed what is being called."""
|
||||
with self._lock:
|
||||
was_paused = self._failures > 0
|
||||
self._reset_locked()
|
||||
if was_paused:
|
||||
logger.info("model_backoff_reset reason=%s", reason)
|
||||
|
||||
def record_success(self, profile_id: str | None = None, kind: str = "model") -> None:
|
||||
with self._lock:
|
||||
if self._failures == 0:
|
||||
return
|
||||
if profile_id is not None and self._profile_id not in {None, profile_id}:
|
||||
return
|
||||
self._failing.discard(kind)
|
||||
if self._failing:
|
||||
return
|
||||
self._reset_locked()
|
||||
logger.info("model_backoff_cleared profile_id=%s", profile_id)
|
||||
|
||||
def record_failure(self, code: str, profile_id: str | None = None, kind: str = "model") -> None:
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
if profile_id is not None and self._profile_id not in {None, profile_id}:
|
||||
# Another profile's history says nothing about this one.
|
||||
self._reset_locked()
|
||||
self._profile_id = profile_id
|
||||
self._failing.add(kind)
|
||||
self._mark_attempts_locked(code)
|
||||
self._failures += 1
|
||||
self._last_error = code
|
||||
self._probe_until = 0.0
|
||||
# Requests that were already out when the pause began fail too;
|
||||
# they are the same outage, not a reason to wait longer.
|
||||
if now >= self._until:
|
||||
self._level += 1
|
||||
self._until = now + backoff_seconds(self._level)
|
||||
level, pause = self._level, self._until - now
|
||||
logger.warning(
|
||||
"model_backoff_failure error_code=%s level=%d pause_s=%.0f profile_id=%s",
|
||||
code,
|
||||
level,
|
||||
pause,
|
||||
profile_id,
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def attempt(self) -> Iterator[ModelAttempt]:
|
||||
"""Hold one job's write open, and learn whether the provider failed it.
|
||||
|
||||
Every provider failure and every local refusal while the write is in
|
||||
flight is charged to it. A request cannot be traced to the job that
|
||||
made it: the SDK sends embeddings from one shared batching worker, so
|
||||
neither the calling thread nor a context variable identifies the job.
|
||||
Charging every write then in flight can name a write the provider did
|
||||
not fail (another job's request failed beside it); that write waits
|
||||
for the provider and is retried with the rest. It can never miss the
|
||||
write the provider did fail, which would spend that job's retries on
|
||||
an outage.
|
||||
"""
|
||||
record = ModelAttempt()
|
||||
with self._lock:
|
||||
self._attempts.add(record)
|
||||
try:
|
||||
yield record
|
||||
finally:
|
||||
with self._lock:
|
||||
self._attempts.discard(record)
|
||||
|
||||
def record_refusal(self) -> str:
|
||||
"""A request refused locally; charged to the writes in flight like a failure."""
|
||||
with self._lock:
|
||||
code = self._last_error or "model_transport_failed"
|
||||
self._mark_attempts_locked(code)
|
||||
return code
|
||||
|
||||
def _mark_attempts_locked(self, code: str) -> None:
|
||||
for record in self._attempts:
|
||||
if record.failure is None:
|
||||
record.failure = code
|
||||
|
||||
def paused(self) -> bool:
|
||||
with self._lock:
|
||||
return self._failures > 0 and self._clock() < self._until
|
||||
|
||||
def refuses_requests(self, kind: str = "model") -> bool:
|
||||
"""Whether a request of this kind should not go out at all right now.
|
||||
|
||||
Only the failing kind is refused: a chat model that is gone must not
|
||||
take recall's embedding calls down with it.
|
||||
"""
|
||||
with self._lock:
|
||||
return (
|
||||
kind in self._failing
|
||||
and self._failures >= REFUSE_IN_FLIGHT_AFTER
|
||||
and self._clock() < self._until
|
||||
)
|
||||
|
||||
def allowance(self, *, kind: str | None = None) -> int | None:
|
||||
"""How many matching provider jobs may be claimed now.
|
||||
|
||||
0 while paused; once the pause is over, one probe at a time until it
|
||||
reports or `PROBE_HOLD_SECONDS` pass. ``kind`` keeps a failed chat
|
||||
model from pausing raw-only work that needs embeddings, while an
|
||||
embedding outage still paces that work.
|
||||
"""
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
if self._failures == 0 or (kind is not None and kind not in self._failing):
|
||||
return None
|
||||
if now < self._until or now < self._probe_until:
|
||||
return 0
|
||||
self._probe_until = now + PROBE_HOLD_SECONDS
|
||||
return 1
|
||||
|
||||
def state(self) -> ModelBackoffState:
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
remaining = self._until - now if self._failures else 0.0
|
||||
return ModelBackoffState(
|
||||
until=(
|
||||
self._wall_clock() + timedelta(seconds=remaining) if remaining > 0 else None
|
||||
),
|
||||
failures=self._failures,
|
||||
last_error=self._last_error,
|
||||
profile_id=self._profile_id,
|
||||
)
|
||||
|
||||
|
||||
def next_retry_at(app: Any, state: str, own: str | None) -> str | None:
|
||||
"""When a queued job is tried next: its own retry wait, or the provider's pause.
|
||||
|
||||
While the breaker pauses, nothing is claimed at all, so the pause holds
|
||||
every queued job, whatever it last failed with.
|
||||
"""
|
||||
if state not in {"accepted", "pending"}:
|
||||
return None
|
||||
backoff = getattr(getattr(app, "state", None), "model_backoff", None)
|
||||
until = backoff.state().until if isinstance(backoff, ModelBackoff) else None
|
||||
paused = until.isoformat() if until is not None else None
|
||||
candidates = [value for value in (own, paused) if value]
|
||||
return max(candidates) if candidates else None
|
||||
|
||||
|
||||
class BackedOffModelTransport(httpx.AsyncBaseTransport):
|
||||
"""Report each provider exchange to the breaker; refuse while it confirms a pause."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
backoff: ModelBackoff,
|
||||
*,
|
||||
profile_id: str,
|
||||
inner: httpx.AsyncBaseTransport | None = None,
|
||||
) -> None:
|
||||
self.backoff = backoff
|
||||
self.profile_id = profile_id
|
||||
self.inner = inner or httpx.AsyncHTTPTransport(retries=0, trust_env=False)
|
||||
|
||||
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
||||
kind = request_kind(request)
|
||||
if self.backoff.refuses_requests(kind):
|
||||
self.backoff.record_refusal()
|
||||
raise ModelBackoffRefusal("model_backoff_active", request=request)
|
||||
try:
|
||||
response = await self.inner.handle_async_request(request)
|
||||
except ModelBackoffRefusal:
|
||||
raise
|
||||
except httpx.TransportError as error:
|
||||
self.backoff.record_failure(transport_failure_code(error), self.profile_id, kind)
|
||||
raise
|
||||
code = failure_code(response.status_code)
|
||||
if code is not None:
|
||||
self.backoff.record_failure(code, self.profile_id, kind)
|
||||
elif response.status_code < 400:
|
||||
self.backoff.record_success(self.profile_id, kind)
|
||||
return response
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self.inner.aclose()
|
||||
@@ -4,9 +4,10 @@ from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from one_memory.database import (
|
||||
take_the_write_lock_first,
|
||||
@@ -14,11 +15,25 @@ from one_memory.database import (
|
||||
from one_memory.database_access import main_database
|
||||
|
||||
#: Schema owned by `memory_server.migrations` (migration 12). No local copy.
|
||||
from one_memory.memory_server.migrations import onboarding_state_ddl
|
||||
from one_memory.memory_server.sql_compat import execute, is_mysql
|
||||
from one_memory.memory_server.migrations import (
|
||||
onboarding_progress_ddl,
|
||||
onboarding_state_ddl,
|
||||
)
|
||||
from one_memory.memory_server.sql_compat import execute, is_mysql, row_to_dict
|
||||
from one_memory.memory_server.store_binding import store_for
|
||||
|
||||
SCHEMA = onboarding_state_ddl("sqlite")
|
||||
ONBOARDING_COLUMNS = (
|
||||
"singleton",
|
||||
"status",
|
||||
"selected_source_ids_json",
|
||||
"started_at",
|
||||
"completed_at",
|
||||
"updated_at",
|
||||
"mode_intent",
|
||||
"setup_step",
|
||||
"model_profile_id",
|
||||
)
|
||||
|
||||
#: The start/complete upserts, per engine.
|
||||
#:
|
||||
@@ -51,11 +66,13 @@ START_MYSQL = """
|
||||
updated_at = VALUES(updated_at)
|
||||
"""
|
||||
COMPLETE_UPDATE_SQLITE = (
|
||||
"UPDATE onboarding_state SET status = 'completed', completed_at = ?, updated_at = ? "
|
||||
"UPDATE onboarding_state SET status = 'completed', completed_at = ?, updated_at = ?, "
|
||||
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL "
|
||||
"WHERE singleton = 1"
|
||||
)
|
||||
COMPLETE_UPDATE_MYSQL = (
|
||||
"UPDATE onboarding_state SET status = 'completed', completed_at = %s, updated_at = %s "
|
||||
"UPDATE onboarding_state SET status = 'completed', completed_at = %s, updated_at = %s, "
|
||||
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL "
|
||||
"WHERE singleton = 1"
|
||||
)
|
||||
COMPLETE_INSERT_SQLITE = (
|
||||
@@ -71,13 +88,27 @@ COMPLETE_INSERT_MYSQL = (
|
||||
RESET_UPDATE_SQLITE = (
|
||||
"UPDATE onboarding_state SET status = 'not_started', "
|
||||
"selected_source_ids_json = '[]', started_at = NULL, completed_at = NULL, "
|
||||
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL, "
|
||||
"updated_at = ? WHERE singleton = 1"
|
||||
)
|
||||
RESET_UPDATE_MYSQL = (
|
||||
"UPDATE onboarding_state SET status = 'not_started', "
|
||||
"selected_source_ids_json = '[]', started_at = NULL, completed_at = NULL, "
|
||||
"mode_intent = NULL, setup_step = NULL, model_profile_id = NULL, "
|
||||
"updated_at = %s WHERE singleton = 1"
|
||||
)
|
||||
ENSURE_PROGRESS_SQLITE = (
|
||||
"INSERT INTO onboarding_state ("
|
||||
"singleton,status,selected_source_ids_json,started_at,completed_at,updated_at"
|
||||
") VALUES (1,'not_started','[]',NULL,NULL,?) "
|
||||
"ON CONFLICT(singleton) DO NOTHING"
|
||||
)
|
||||
ENSURE_PROGRESS_MYSQL = (
|
||||
"INSERT INTO onboarding_state ("
|
||||
"singleton,status,selected_source_ids_json,started_at,completed_at,updated_at"
|
||||
") VALUES (1,'not_started','[]',NULL,NULL,%s) "
|
||||
"ON DUPLICATE KEY UPDATE singleton=VALUES(singleton)"
|
||||
)
|
||||
|
||||
|
||||
class OnboardingState(BaseModel):
|
||||
@@ -87,6 +118,35 @@ class OnboardingState(BaseModel):
|
||||
selected_source_ids: tuple[str, ...]
|
||||
started_at: datetime | None
|
||||
completed_at: datetime | None
|
||||
mode_intent: Literal["lite", "pro"] | None
|
||||
setup_step: Literal["review", "mode", "model"] | None
|
||||
model_profile_id: str | None
|
||||
|
||||
|
||||
class OnboardingProgressUpdate(BaseModel):
|
||||
"""Only non-sensitive, resumable choices accepted from the onboarding UI."""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
mode_intent: Literal["lite", "pro"] | None = None
|
||||
setup_step: Literal["review", "mode", "model"] | None = None
|
||||
model_profile_id: str | None = Field(default=None, min_length=36, max_length=36)
|
||||
|
||||
@field_validator("model_profile_id")
|
||||
@classmethod
|
||||
def validate_model_profile_id(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
if str(UUID(value)) != value:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
raise ValueError("invalid_model_profile_id") from None
|
||||
return value
|
||||
|
||||
|
||||
class OnboardingCompletedError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class OnboardingStore:
|
||||
@@ -103,12 +163,31 @@ class OnboardingStore:
|
||||
"""
|
||||
with self._connect(initialize=True) as connection:
|
||||
execute(connection, is_mysql(self._store), SCHEMA)
|
||||
if not is_mysql(self._store):
|
||||
existing = {
|
||||
str(row[1]) for row in connection.execute("PRAGMA table_info(onboarding_state)")
|
||||
}
|
||||
statements = tuple(
|
||||
statement.strip()
|
||||
for statement in onboarding_progress_ddl("sqlite").split(";")
|
||||
if statement.strip()
|
||||
)
|
||||
for column, statement in zip(
|
||||
("mode_intent", "setup_step", "model_profile_id"),
|
||||
statements,
|
||||
strict=True,
|
||||
):
|
||||
if column not in existing:
|
||||
connection.execute(statement)
|
||||
|
||||
def get(self) -> OnboardingState:
|
||||
mysql = is_mysql(self._store)
|
||||
with self._connect() as connection:
|
||||
row = execute(
|
||||
connection, mysql, "SELECT * FROM onboarding_state WHERE singleton = 1"
|
||||
connection,
|
||||
mysql,
|
||||
"SELECT " + ", ".join(ONBOARDING_COLUMNS) + " "
|
||||
"FROM onboarding_state WHERE singleton = 1",
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return OnboardingState(
|
||||
@@ -116,8 +195,11 @@ class OnboardingStore:
|
||||
selected_source_ids=(),
|
||||
started_at=None,
|
||||
completed_at=None,
|
||||
mode_intent=None,
|
||||
setup_step=None,
|
||||
model_profile_id=None,
|
||||
)
|
||||
return self._to_state(row)
|
||||
return self._to_state(row, mysql=mysql)
|
||||
|
||||
def start(self, source_ids: tuple[str, ...]) -> OnboardingState:
|
||||
now = datetime.now(UTC).isoformat()
|
||||
@@ -150,6 +232,43 @@ class OnboardingStore:
|
||||
)
|
||||
return self.get()
|
||||
|
||||
def update_progress(self, update: OnboardingProgressUpdate) -> OnboardingState:
|
||||
fields = tuple(
|
||||
field
|
||||
for field in ("mode_intent", "setup_step", "model_profile_id")
|
||||
if field in update.model_fields_set
|
||||
)
|
||||
if not fields:
|
||||
state = self.get()
|
||||
if state.status == "completed":
|
||||
raise OnboardingCompletedError("onboarding_completed")
|
||||
return state
|
||||
mysql = is_mysql(self._store)
|
||||
placeholder = "%s" if mysql else "?"
|
||||
assignments = [f"{field} = {placeholder}" for field in fields]
|
||||
assignments.append(f"updated_at = {placeholder}")
|
||||
values = [getattr(update, field) for field in fields]
|
||||
now = datetime.now(UTC).isoformat()
|
||||
values.append(now)
|
||||
with self._connect(writing=True) as connection:
|
||||
execute(
|
||||
connection,
|
||||
mysql,
|
||||
ENSURE_PROGRESS_MYSQL if mysql else ENSURE_PROGRESS_SQLITE,
|
||||
(now,),
|
||||
)
|
||||
updated = execute(
|
||||
connection,
|
||||
mysql,
|
||||
"UPDATE onboarding_state SET "
|
||||
+ ", ".join(assignments)
|
||||
+ " WHERE singleton = 1 AND status != 'completed'",
|
||||
tuple(values),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise OnboardingCompletedError("onboarding_completed")
|
||||
return self.get()
|
||||
|
||||
def reset(self) -> OnboardingState:
|
||||
"""Return onboarding to its first-run state, touching nothing else.
|
||||
|
||||
@@ -207,33 +326,53 @@ class OnboardingStore:
|
||||
yield connection
|
||||
|
||||
@staticmethod
|
||||
def _to_state(row: Any) -> OnboardingState:
|
||||
"""Read the row by position, so it works for `sqlite3.Row` and tuples.
|
||||
|
||||
`SELECT *` makes the column order part of the contract here, which is
|
||||
why the query is `SELECT *` and the indices are named: relying on
|
||||
`row["..."]` would only work on SQLite, and relying on position alone
|
||||
without saying which column is which would be unreadable.
|
||||
|
||||
Timestamps are a further engine difference: SQLite hands back the stored
|
||||
string, MySQL hands back a `datetime`. Accepting both is not padding —
|
||||
parsing a `datetime` raises, and treating a string as one would be
|
||||
silently wrong.
|
||||
"""
|
||||
# singleton, status, selected_source_ids_json, started_at, completed_at, updated_at
|
||||
raw_ids = json.loads(row[2])
|
||||
def _to_state(row: Any, *, mysql: bool) -> OnboardingState:
|
||||
"""Map SQLite rows and MySQL tuples through the shared database adapter."""
|
||||
if mysql:
|
||||
if not isinstance(row, tuple):
|
||||
raise ValueError("onboarding_row_shape_invalid")
|
||||
elif not isinstance(row, sqlite3.Row):
|
||||
raise ValueError("onboarding_row_shape_invalid")
|
||||
if len(row) != len(ONBOARDING_COLUMNS):
|
||||
raise ValueError("onboarding_row_shape_invalid")
|
||||
if mysql:
|
||||
values = dict(zip(ONBOARDING_COLUMNS, tuple(row), strict=True))
|
||||
else:
|
||||
values = row_to_dict(row, ONBOARDING_COLUMNS, mysql=False)
|
||||
if set(values) != set(ONBOARDING_COLUMNS):
|
||||
raise ValueError("onboarding_row_shape_invalid")
|
||||
_as_datetime(values["updated_at"], mysql=mysql, nullable=False)
|
||||
raw_ids = json.loads(values["selected_source_ids_json"])
|
||||
return OnboardingState(
|
||||
status=row[1],
|
||||
status=values["status"],
|
||||
selected_source_ids=tuple(value for value in raw_ids if isinstance(value, str)),
|
||||
started_at=_as_datetime(row[3]),
|
||||
completed_at=_as_datetime(row[4]),
|
||||
started_at=_as_datetime(values["started_at"], mysql=mysql),
|
||||
completed_at=_as_datetime(values["completed_at"], mysql=mysql),
|
||||
mode_intent=values["mode_intent"],
|
||||
setup_step=values["setup_step"],
|
||||
model_profile_id=values["model_profile_id"],
|
||||
)
|
||||
|
||||
|
||||
def _as_datetime(value: object) -> datetime | None:
|
||||
"""SQLite stores these as text; MySQL returns `datetime` objects."""
|
||||
if value is None or value == "":
|
||||
def _as_datetime(
|
||||
value: object,
|
||||
*,
|
||||
mysql: bool,
|
||||
nullable: bool = True,
|
||||
) -> datetime | None:
|
||||
"""Validate the engine's timestamp shape and normalize it to UTC."""
|
||||
if value is None:
|
||||
if not nullable:
|
||||
raise ValueError("onboarding_datetime_invalid")
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
return datetime.fromisoformat(str(value))
|
||||
if mysql:
|
||||
if not isinstance(value, datetime):
|
||||
raise ValueError("onboarding_datetime_invalid")
|
||||
parsed = value
|
||||
else:
|
||||
if not isinstance(value, str) or value == "":
|
||||
raise ValueError("onboarding_datetime_invalid")
|
||||
parsed = datetime.fromisoformat(value)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=UTC)
|
||||
return parsed.astimezone(UTC)
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Turn-level read projection for SDK raw batches.
|
||||
|
||||
HY keeps several complete conversation turns in one L1 raw node so model
|
||||
writes stay batched. Product readers must not expose that storage batch as
|
||||
one conversation item: this module gives every contained turn a stable
|
||||
identity while retaining the parent raw id for traceability.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from one_memory.hy_sdk_types import HySdkRawRecord, raw_turn_messages
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RawDisplayTurn:
|
||||
turn_id: str
|
||||
turn_index: int
|
||||
turn_count: int
|
||||
content: str
|
||||
messages: tuple[tuple[str, str], ...]
|
||||
|
||||
|
||||
def raw_display_turns(record: HySdkRawRecord) -> tuple[RawDisplayTurn, ...]:
|
||||
"""Split one stored raw batch into user-opened turns.
|
||||
|
||||
A user message opens a new turn; assistant/tool/system messages stay with
|
||||
that user message until the next user message. Legacy content with no user
|
||||
marker remains one turn so it never disappears from the product.
|
||||
"""
|
||||
messages = raw_turn_messages(record.content)
|
||||
grouped: list[list[tuple[str, str]]] = []
|
||||
for message in messages:
|
||||
role, _ = message
|
||||
has_user = bool(grouped) and any(item[0] == "user" for item in grouped[-1])
|
||||
if not grouped or (role == "user" and has_user):
|
||||
grouped.append([message])
|
||||
else:
|
||||
grouped[-1].append(message)
|
||||
if not grouped:
|
||||
grouped = [[("", record.content)]]
|
||||
|
||||
digest = hashlib.sha256(record.raw_id.encode("utf-8")).hexdigest()
|
||||
count = len(grouped)
|
||||
return tuple(
|
||||
RawDisplayTurn(
|
||||
turn_id=f"raw-turn:{digest}:{index}",
|
||||
turn_index=index,
|
||||
turn_count=count,
|
||||
content=_turn_content(turn),
|
||||
messages=tuple(turn),
|
||||
)
|
||||
for index, turn in enumerate(grouped)
|
||||
)
|
||||
|
||||
|
||||
def _turn_content(messages: list[tuple[str, str]]) -> str:
|
||||
return "\n".join(f"[{role}]: {content}" if role else content for role, content in messages)
|
||||
|
||||
|
||||
def raw_prefix_added(
|
||||
cached: tuple[tuple[str, str | None], ...],
|
||||
current: tuple[tuple[str, str | None], ...],
|
||||
cached_total: int,
|
||||
current_total: int,
|
||||
) -> int | None:
|
||||
"""How many raws were only put on top of a cached newest-first listing.
|
||||
|
||||
`cached` and `current` are the (raw id, status) of the first page of each
|
||||
listing. The answer is a count when the current page is the cached one
|
||||
with new raws before it and nothing else changed; None when anything else
|
||||
happened (a delete, a status change, a raw dated into the middle), which
|
||||
needs a full re-listing.
|
||||
"""
|
||||
added = current_total - cached_total
|
||||
if added <= 0 or added > len(current):
|
||||
return None
|
||||
kept = current[added:]
|
||||
if not kept and cached_total:
|
||||
return None
|
||||
if kept != cached[: len(kept)]:
|
||||
return None
|
||||
return added
|
||||
|
||||
|
||||
_WORD = re.compile(r"\w+")
|
||||
|
||||
|
||||
def _query_terms(query: str) -> frozenset[str]:
|
||||
"""Words of the query, plus character bigrams of unspaced (CJK) words."""
|
||||
terms: set[str] = set()
|
||||
for word in _WORD.findall(query.casefold()):
|
||||
terms.add(word)
|
||||
if not word.isascii() and len(word) > 1:
|
||||
terms.update(word[index : index + 2] for index in range(len(word) - 1))
|
||||
return frozenset(terms)
|
||||
|
||||
|
||||
def turns_for_hit(turns: tuple[RawDisplayTurn, ...], query: str) -> tuple[RawDisplayTurn, ...]:
|
||||
"""The turns one semantic raw hit shows, never none while it has turns.
|
||||
|
||||
The SDK embeds a whole raw batch, so a vector hit cannot say which of its
|
||||
turns matched. Every turn containing the query text is shown; when none
|
||||
does, the turn sharing the most query terms (the earliest on a tie) stands
|
||||
for the hit. A semantic hit is never dropped for lacking the literal query.
|
||||
"""
|
||||
if not turns:
|
||||
return ()
|
||||
needle = query.casefold().strip()
|
||||
if needle:
|
||||
lexical = tuple(turn for turn in turns if needle in turn.content.casefold())
|
||||
if lexical:
|
||||
return lexical
|
||||
terms = _query_terms(query)
|
||||
|
||||
def overlap(turn: RawDisplayTurn) -> tuple[int, int]:
|
||||
content = turn.content.casefold()
|
||||
return (sum(1 for term in terms if term in content), -turn.turn_index)
|
||||
|
||||
return (max(turns, key=overlap),)
|
||||
@@ -32,6 +32,19 @@ FallbackReason = Literal["model_unavailable", "no_memories"]
|
||||
#: caller). An empty set means the caller may read no raw conversation.
|
||||
RawSources = frozenset[str] | None
|
||||
|
||||
#: What the whole recall request may take beyond the caller's own
|
||||
#: `timeout_ms`: the search keeps to that budget, but the checks around it
|
||||
#: (is a model configured, is the library empty, which raw sources) and the
|
||||
#: raw fallback need a little room of their own. Well under the broker's 15 s
|
||||
#: wait on Core, so a slow Core answers 504 before the broker gives up.
|
||||
RECALL_TOTAL_GRACE_SECONDS = 3.0
|
||||
RECALL_TOTAL_CEILING_SECONDS = 12.0
|
||||
|
||||
|
||||
def recall_total_timeout_seconds(timeout_ms: int) -> float:
|
||||
"""The most one recall request may take from start to answer."""
|
||||
return min(timeout_ms / 1_000 + RECALL_TOTAL_GRACE_SECONDS, RECALL_TOTAL_CEILING_SECONDS)
|
||||
|
||||
|
||||
async def recall_with_fallback(
|
||||
request: RecallRequest,
|
||||
|
||||
@@ -25,7 +25,7 @@ from one_memory.hy_runtime_config import HY_MEMORY_READER
|
||||
from one_memory.hy_sdk_runtime import SdkClientHandle, sdk_relevance_floor
|
||||
from one_memory.hy_sdk_types import HySdkError, parse_sdk_search
|
||||
from one_memory.memory_queue import ExtractionWindowPreset, QueueDepths
|
||||
from one_memory.memory_server.durable_queue import COMPLETED, DurableWriteQueue
|
||||
from one_memory.memory_server.durable_queue import WAITING_FOR_MODEL, DurableWriteQueue
|
||||
from one_memory.sdk_write_processor import SdkWriteCoordinator
|
||||
from one_memory.semantic_deadline import SemanticMemoryHit
|
||||
|
||||
@@ -34,6 +34,10 @@ _STABLE_CODE = re.compile(r"[a-z][a-z0-9_]{0,95}")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Jobs not yet run that a suspended source holds back.
|
||||
_WAITING_STATES = frozenset({"pending", "waiting_for_model", "waiting_for_permission"})
|
||||
_WAITING_SQL = ",".join(f"'{state}'" for state in sorted(_WAITING_STATES))
|
||||
|
||||
|
||||
class SdkMemoryRuntime:
|
||||
"""Product services that remain after legacy task/governance writes retire."""
|
||||
@@ -48,12 +52,19 @@ class SdkMemoryRuntime:
|
||||
data_dir: Path,
|
||||
memory_mode: Callable[[], str] = lambda: "pro",
|
||||
writes_open: Callable[[], bool] = lambda: True,
|
||||
source_active: Callable[[str], bool] = lambda _source_key: True,
|
||||
) -> None:
|
||||
self.coordinator = coordinator
|
||||
#: The SDK mode, for the graph store's path (`hy_graph_admin`).
|
||||
self.memory_mode = memory_mode
|
||||
#: False while an embedding switch pauses model writes.
|
||||
self.writes_open = writes_open
|
||||
#: Whether a queue lane's source may still become memories: the same
|
||||
#: question the drain's claim asks of it. A lane that is off (the
|
||||
#: user turned the source off, or its grant was revoked) has its
|
||||
#: waiting jobs suspended: kept, never claimed, and not counted as
|
||||
#: pending until the source is on again.
|
||||
self.source_active = source_active
|
||||
self.read_handle = read_handle
|
||||
self.write_queue = write_queue
|
||||
self.extraction_settings = extraction_settings
|
||||
@@ -241,22 +252,11 @@ class SdkMemoryRuntime:
|
||||
now = datetime.now(UTC)
|
||||
fresh_since = (now - timedelta(days=7)).isoformat()
|
||||
with main_database(self.write_queue.database_path).read() as database:
|
||||
fresh = int(
|
||||
database.execute(
|
||||
"SELECT COUNT(*) FROM memory_write_jobs "
|
||||
"WHERE state IN ('pending','waiting_for_model','waiting_for_permission') "
|
||||
"AND event_at>=?",
|
||||
(fresh_since,),
|
||||
).fetchone()[0]
|
||||
)
|
||||
backlog = int(
|
||||
database.execute(
|
||||
"SELECT COUNT(*) FROM memory_write_jobs "
|
||||
"WHERE state IN ('pending','waiting_for_model','waiting_for_permission') "
|
||||
"AND event_at<?",
|
||||
(fresh_since,),
|
||||
).fetchone()[0]
|
||||
)
|
||||
rows = database.execute(
|
||||
"SELECT source_key,event_at>=? AS fresh,COUNT(*) FROM memory_write_jobs "
|
||||
f"WHERE state IN ({_WAITING_SQL}) GROUP BY source_key,fresh",
|
||||
(fresh_since,),
|
||||
).fetchall()
|
||||
recent = tuple(
|
||||
datetime.fromisoformat(str(row[0]))
|
||||
for row in database.execute(
|
||||
@@ -264,6 +264,9 @@ class SdkMemoryRuntime:
|
||||
"ORDER BY updated_at DESC LIMIT 8"
|
||||
)
|
||||
)
|
||||
active = self._active_lanes({str(row[0]) for row in rows})
|
||||
fresh = sum(int(row[2]) for row in rows if str(row[0]) in active and row[1])
|
||||
backlog = sum(int(row[2]) for row in rows if str(row[0]) in active and not row[1])
|
||||
return QueueDepths(fresh=fresh, backlog=backlog, recent_completions=recent)
|
||||
|
||||
def active_depths(self) -> tuple[int, int]:
|
||||
@@ -276,13 +279,27 @@ class SdkMemoryRuntime:
|
||||
return running, running
|
||||
|
||||
def status_counts(self) -> dict[str, int]:
|
||||
"""Jobs per state, with a suspended source's waiting jobs as `suspended`.
|
||||
|
||||
Those jobs keep their state in the table: suspension is the source's
|
||||
switch, not the row's, so turning the source back on is all it takes
|
||||
for the drain to claim them again, and nothing is moved or deleted.
|
||||
"""
|
||||
with main_database(self.write_queue.database_path).read() as database:
|
||||
return {
|
||||
str(row[0]): int(row[1])
|
||||
for row in database.execute(
|
||||
"SELECT state,COUNT(*) FROM memory_write_jobs GROUP BY state"
|
||||
)
|
||||
}
|
||||
rows = database.execute(
|
||||
"SELECT source_key,state,COUNT(*) FROM memory_write_jobs GROUP BY source_key,state"
|
||||
).fetchall()
|
||||
active = self._active_lanes({str(row[0]) for row in rows if str(row[1]) in _WAITING_STATES})
|
||||
counts: dict[str, int] = {}
|
||||
for source_key, state, count in rows:
|
||||
name = str(state)
|
||||
if name in _WAITING_STATES and str(source_key) not in active:
|
||||
name = "suspended"
|
||||
counts[name] = counts.get(name, 0) + int(count)
|
||||
return counts
|
||||
|
||||
def _active_lanes(self, lanes: set[str]) -> set[str]:
|
||||
return {lane for lane in lanes if self.source_active(lane)}
|
||||
|
||||
def manual_batch_progress(self) -> tuple[int, int]:
|
||||
return 0, 0
|
||||
@@ -291,33 +308,11 @@ class SdkMemoryRuntime:
|
||||
del task_ids
|
||||
return []
|
||||
|
||||
async def reconcile_unknown_outputs(
|
||||
self,
|
||||
*,
|
||||
limit: int = 128,
|
||||
exclude_error_codes: tuple[str, ...] = (),
|
||||
) -> int:
|
||||
reconciled = 0
|
||||
for job in await asyncio.to_thread(
|
||||
self.write_queue.unknown_jobs,
|
||||
limit=limit,
|
||||
exclude_error_codes=exclude_error_codes,
|
||||
):
|
||||
receipt = await self.coordinator.inquire_write_outcome(job.request_id)
|
||||
if receipt is None:
|
||||
continue
|
||||
await asyncio.to_thread(
|
||||
self.write_queue.settle,
|
||||
job.job_id,
|
||||
state=COMPLETED,
|
||||
sdk_raw_ids=receipt.raw_ids,
|
||||
sdk_memory_ids=receipt.memory_ids,
|
||||
)
|
||||
reconciled += 1
|
||||
return reconciled
|
||||
|
||||
async def resume_allowed(self) -> None:
|
||||
await asyncio.to_thread(self.write_queue.readmit_parked)
|
||||
await asyncio.to_thread(
|
||||
self.write_queue.readmit_parked,
|
||||
states=(WAITING_FOR_MODEL,),
|
||||
)
|
||||
|
||||
def accepted_backfill_status(self) -> None:
|
||||
return None
|
||||
|
||||
@@ -29,6 +29,7 @@ from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from one_memory.background_work import in_sdk_read
|
||||
from one_memory.embedding_health import EmbeddingGuardedClient, EmbeddingHealth
|
||||
from one_memory.embedding_spaces import (
|
||||
EmbeddingSpace,
|
||||
@@ -93,6 +94,11 @@ class ActiveProfileReadHandle(SdkClientHandle):
|
||||
handle = await self.current_handle()
|
||||
return self._guarded(handle, await handle.client())
|
||||
|
||||
async def client_for_mode(self, mode: str) -> Any:
|
||||
"""Build/read with the caller's one mode snapshot, without rereading it."""
|
||||
handle = await self.current_handle(mode=mode)
|
||||
return self._guarded(handle, await handle.client())
|
||||
|
||||
def _guarded(self, handle: SdkClientHandle, client: Any) -> Any:
|
||||
"""A remote embedding's searches report `embedding_unavailable` (no fallback)."""
|
||||
profile = getattr(handle, "profile", None)
|
||||
@@ -110,15 +116,15 @@ class ActiveProfileReadHandle(SdkClientHandle):
|
||||
),
|
||||
)
|
||||
|
||||
async def current_handle(self) -> SdkClientHandle:
|
||||
async def current_handle(self, *, mode: str | None = None) -> SdkClientHandle:
|
||||
"""The handle of the live space, held for the asking task."""
|
||||
space, identity = await asyncio.to_thread(self._resolve)
|
||||
mode, features = self.memory_mode(), self.current_features()
|
||||
space, identity = await in_sdk_read(self._resolve)
|
||||
selected_mode, features = mode or self.memory_mode(), self.current_features()
|
||||
language = self.current_language()
|
||||
key = (
|
||||
space.space_id if space is not None else "",
|
||||
*identity,
|
||||
mode,
|
||||
selected_mode,
|
||||
features,
|
||||
language,
|
||||
)
|
||||
@@ -127,8 +133,8 @@ class ActiveProfileReadHandle(SdkClientHandle):
|
||||
if handle is None:
|
||||
# Only on a change of space or profile: resolving a key can
|
||||
# reach the system keychain, so it is not done on every read.
|
||||
handle = await asyncio.to_thread(
|
||||
self._build, space, identity, mode, features, language
|
||||
handle = await in_sdk_read(
|
||||
self._build, space, identity, selected_mode, features, language
|
||||
)
|
||||
self._handles[key] = handle
|
||||
self._retired.update(value for name, value in self._handles.items() if name != key)
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import threading
|
||||
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
|
||||
|
||||
from one_memory.background_work import in_sdk_write
|
||||
from one_memory.embedding_spaces import (
|
||||
EmbeddingSpace,
|
||||
EmbeddingSpaceStore,
|
||||
@@ -20,14 +23,70 @@ from one_memory.hy_sdk_runtime import (
|
||||
SdkClientHandle,
|
||||
builtin_retrieval_snapshot,
|
||||
language_kwargs,
|
||||
retrieval_view,
|
||||
)
|
||||
from one_memory.imports.models import EvidenceContentKind, EvidenceOrigin, EvidenceRecord
|
||||
from one_memory.input_sanitization import sanitize_model_input
|
||||
from one_memory.memory_features import FeatureOverrides
|
||||
from one_memory.model_profiles import ModelProfileSnapshot, ModelProfileStore
|
||||
from one_memory.model_profiles import ModelProfileSnapshot, ModelProfileStore, ProviderKind
|
||||
from one_memory.secrets import SecretStore, SecretStoreUnavailableError
|
||||
|
||||
PRODUCT_AGENT_ID = "all-apps"
|
||||
|
||||
#: SDK request ids whose write call is running on a thread right now. A write
|
||||
#: the drain gave up on (its work ceiling, a lost lease) keeps running on its
|
||||
#: thread; it must not be re-run beside itself (`recover_unknown`).
|
||||
_IN_FLIGHT: set[str] = set()
|
||||
_IN_FLIGHT_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def sdk_write_in_flight(request_id: str) -> bool:
|
||||
with _IN_FLIGHT_LOCK:
|
||||
return request_id in _IN_FLIGHT
|
||||
|
||||
|
||||
class _Tracked:
|
||||
"""One SDK write call, held in `_IN_FLIGHT` from submission until it ends.
|
||||
|
||||
If the wait for it is cancelled before a thread picked it up, it is
|
||||
abandoned: it will not start, and it leaves `_IN_FLIGHT` at once.
|
||||
"""
|
||||
|
||||
def __init__(self, request_id: str, work: Callable[..., Any]) -> None:
|
||||
self.request_id = request_id
|
||||
self.work = work
|
||||
self.started = False
|
||||
self.abandoned = False
|
||||
with _IN_FLIGHT_LOCK:
|
||||
_IN_FLIGHT.add(request_id)
|
||||
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
||||
with _IN_FLIGHT_LOCK:
|
||||
if self.abandoned:
|
||||
raise RuntimeError("hy_memory_write_abandoned")
|
||||
self.started = True
|
||||
try:
|
||||
return self.work(*args, **kwargs)
|
||||
finally:
|
||||
with _IN_FLIGHT_LOCK:
|
||||
_IN_FLIGHT.discard(self.request_id)
|
||||
|
||||
def abandon(self) -> None:
|
||||
with _IN_FLIGHT_LOCK:
|
||||
if not self.started:
|
||||
self.abandoned = True
|
||||
_IN_FLIGHT.discard(self.request_id)
|
||||
|
||||
|
||||
async def _write(tracked_id: str, work: Callable[..., Any], /, *args: Any, **kwargs: Any) -> Any:
|
||||
call = _Tracked(tracked_id, work)
|
||||
try:
|
||||
return await in_sdk_write(call, *args, **kwargs)
|
||||
except asyncio.CancelledError:
|
||||
call.abandon()
|
||||
raise
|
||||
|
||||
|
||||
_MESSAGE_ROLES = frozenset({"user", "assistant", "tool"})
|
||||
_CONVERSATION_ROLES = frozenset({"user", "assistant"})
|
||||
|
||||
@@ -54,7 +113,7 @@ class SdkWriteProcessor:
|
||||
records: tuple[EvidenceRecord, ...],
|
||||
*,
|
||||
request_id: str,
|
||||
before_hy_invocation: Callable[[], Awaitable[None]],
|
||||
before_hy_invocation: Callable[[bool], Awaitable[None]],
|
||||
) -> SdkWriteReceipt:
|
||||
if not records:
|
||||
raise RuntimeError("hy_memory_task_without_inputs")
|
||||
@@ -81,8 +140,9 @@ class SdkWriteProcessor:
|
||||
|
||||
if extracted and not self._lite:
|
||||
contents = [sanitize_model_input(record.verbatim) for record in records]
|
||||
await before_hy_invocation()
|
||||
result = await asyncio.to_thread(
|
||||
await before_hy_invocation(True)
|
||||
result = await _write(
|
||||
request_id,
|
||||
client.add_extracted,
|
||||
contents,
|
||||
user_id=scope.user_id,
|
||||
@@ -108,8 +168,9 @@ class SdkWriteProcessor:
|
||||
]
|
||||
if not messages:
|
||||
raise RuntimeError("hy_memory_task_without_inputs")
|
||||
await before_hy_invocation()
|
||||
result = await asyncio.to_thread(
|
||||
await before_hy_invocation(not self._lite)
|
||||
result = await _write(
|
||||
request_id,
|
||||
client.add,
|
||||
messages,
|
||||
user_id=scope.user_id,
|
||||
@@ -130,7 +191,7 @@ class SdkWriteProcessor:
|
||||
if not request_id:
|
||||
raise ValueError("request_id is required")
|
||||
client = await self._client_handle.client()
|
||||
outcome = await asyncio.to_thread(client.get_write_outcome, request_id)
|
||||
outcome = await in_sdk_write(client.get_write_outcome, request_id)
|
||||
if outcome is None:
|
||||
return None
|
||||
if not isinstance(outcome, Mapping):
|
||||
@@ -223,10 +284,8 @@ class SdkWriteCoordinator:
|
||||
records: tuple[EvidenceRecord, ...],
|
||||
*,
|
||||
request_id: str,
|
||||
before_hy_invocation: Callable[[], Awaitable[None]],
|
||||
before_hy_invocation: Callable[[bool], Awaitable[None]],
|
||||
) -> SdkWriteReceipt:
|
||||
if not self.sedimentation_enabled():
|
||||
raise RuntimeError("model_sedimentation_disabled")
|
||||
if not self.writes_open():
|
||||
# An embedding switch paused writes: the drain puts the job back.
|
||||
raise RuntimeError("embedding_switch_in_progress")
|
||||
@@ -237,9 +296,8 @@ class SdkWriteCoordinator:
|
||||
self._writing[user_id] = self._writing.get(user_id, 0) + 1
|
||||
try:
|
||||
async with self._active_handle() as handle:
|
||||
return await SdkWriteProcessor(
|
||||
handle, lite=getattr(handle, "mode", None) == "lite"
|
||||
).process_sdk_job(
|
||||
lite = getattr(handle, "mode", None) == "lite"
|
||||
return await SdkWriteProcessor(handle, lite=lite).process_sdk_job(
|
||||
records,
|
||||
request_id=request_id,
|
||||
before_hy_invocation=before_hy_invocation,
|
||||
@@ -406,24 +464,27 @@ class SdkWriteCoordinator:
|
||||
return (builtin.profile_id, builtin.revision), builtin, None, space
|
||||
identity = (space.profile_id, space.profile_revision)
|
||||
profile = self.profiles.snapshot(*identity)
|
||||
try:
|
||||
secret = self.profiles.resolve_snapshot_secret(*identity, self.secrets)
|
||||
except SecretStoreUnavailableError:
|
||||
raise RuntimeError("secure_storage_unavailable") from None
|
||||
if profile.has_api_key and secret is None:
|
||||
raise RuntimeError("model_secret_unavailable")
|
||||
return identity, profile, secret, space
|
||||
view = retrieval_view(profile)
|
||||
secret: str | None = None
|
||||
if view.embedding_provider_kind is ProviderKind.OPENAI_COMPATIBLE:
|
||||
try:
|
||||
secret = self.profiles.resolve_snapshot_secret(*identity, self.secrets)
|
||||
except SecretStoreUnavailableError:
|
||||
raise RuntimeError("secure_storage_unavailable") from None
|
||||
if secret is None:
|
||||
raise RuntimeError("model_secret_unavailable")
|
||||
return identity, view, secret, space
|
||||
|
||||
def _read_active_credentials(
|
||||
self,
|
||||
mode: str = "pro",
|
||||
) -> tuple[tuple[str, int], ModelProfileSnapshot, str | None, EmbeddingSpace | None]:
|
||||
if mode == "lite":
|
||||
# A lite write is an embedding-only view of the live space. Never
|
||||
# resolve or inject the generation model endpoint or its secret.
|
||||
return self._lite_credentials()
|
||||
identity = self.profiles.active_identity()
|
||||
if identity is None:
|
||||
if mode == "lite":
|
||||
# lite stores raw turns with one embedding and calls no model:
|
||||
# the live space's own, so they land where reads look.
|
||||
return self._lite_credentials()
|
||||
raise RuntimeError("model_profile_not_active")
|
||||
profile = self.profiles.snapshot(*identity)
|
||||
space = self._live_space() if self.spaces is not None else None
|
||||
@@ -509,11 +570,36 @@ def _is_extracted_content(record: EvidenceRecord) -> bool:
|
||||
)
|
||||
|
||||
|
||||
#: The SDK's stable failure codes are upper snake case (`RECONCILE_FAILED`).
|
||||
_SDK_FAILURE_CODE = re.compile(r"[A-Z][A-Z0-9_]{0,47}")
|
||||
|
||||
|
||||
def sdk_write_failure_code(value: Mapping[object, object]) -> str:
|
||||
"""The job's `error_code` for a failed SDK write: the SDK's own cause if it gave one.
|
||||
|
||||
hy-memory 2.0.13 names why a write failed: `agent_error_code`
|
||||
(`LLM_ERROR`, `EMPTY_RESPONSE`, `JSON_PARSE_FAILED`, `EMBEDDING_FAILED`,
|
||||
`RECONCILE_FAILED`) and, for a failed reconcile, `reconcile_error_code`
|
||||
(`LLM_UNAVAILABLE`, `EMPTY_RESPONSE`, `JSON_PARSE_FAILED`,
|
||||
`RECONCILE_EXCEPTION`). They become `hy_sdk_reconcile_empty_response`,
|
||||
`hy_sdk_llm_error`, ... Without a recognisable code it stays
|
||||
`hy_sdk_write_failed`. Only the code is kept, never the SDK's message.
|
||||
"""
|
||||
reconcile = value.get("reconcile_error_code")
|
||||
if isinstance(reconcile, str) and _SDK_FAILURE_CODE.fullmatch(reconcile):
|
||||
cause = reconcile.removeprefix("RECONCILE_") or "FAILED"
|
||||
return f"hy_sdk_reconcile_{cause.lower()}"
|
||||
agent = value.get("agent_error_code")
|
||||
if isinstance(agent, str) and _SDK_FAILURE_CODE.fullmatch(agent):
|
||||
return f"hy_sdk_{agent.lower()}"
|
||||
return "hy_sdk_write_failed"
|
||||
|
||||
|
||||
def _receipt_from_result(value: object) -> SdkWriteReceipt:
|
||||
if not isinstance(value, Mapping):
|
||||
raise RuntimeError("hy_sdk_write_receipt_shape_invalid")
|
||||
if value.get("success") is not True:
|
||||
raise RuntimeError("hy_sdk_write_failed")
|
||||
raise RuntimeError(sdk_write_failure_code(value))
|
||||
|
||||
# Read only documented public receipt fields. ``variant`` identifies the
|
||||
# productivity namespace; IDs from both variants are canonical memory IDs.
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Stable UI projections over the HY SDK's mode-specific storage shapes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from one_memory.hy_sdk_types import (
|
||||
HySdkMemoryPage,
|
||||
HySdkMemoryRecord,
|
||||
HySdkRawPage,
|
||||
HySdkRawRecord,
|
||||
raw_turn_messages,
|
||||
)
|
||||
from one_memory.imports.sources.parsing import unwrap_host_envelope_with_positions
|
||||
|
||||
MemoryMode = Literal["lite", "pro", "ultra"]
|
||||
TimeBasis = Literal["conversation", "import", "write", "update"]
|
||||
ProjectionDisplayState = Literal["empty", "excerpt", "no_user_excerpt", "no_displayable_memory"]
|
||||
RAW_STATUSES = ("active", "shadow")
|
||||
HOME_PROJECTION_CANDIDATES = 10
|
||||
HOME_EXCERPT_CODEPOINTS = 200
|
||||
|
||||
_SPACE = re.compile(r"\s+")
|
||||
_CONTROL_ONLY = re.compile(
|
||||
r"^(?:"
|
||||
r"<<?\s*(?:image|attachment|system[_-]?reminder|no[_ -]?content)\s*>>?"
|
||||
r"|\[\s*(?:image|attachment|system[_ -]?reminder|no[_ -]?content)\s*\]"
|
||||
r")$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class HomeProjectionCapabilities(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
content_plane: Literal["raw", "memory"]
|
||||
generation_required: bool
|
||||
graph_enabled: bool
|
||||
evolve_available: bool
|
||||
|
||||
|
||||
class _HomeLatest(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
time: datetime | None
|
||||
time_basis: TimeBasis | None
|
||||
excerpt: str
|
||||
truncated: bool
|
||||
source_id: str
|
||||
|
||||
|
||||
class HomeRawLatest(_HomeLatest):
|
||||
type: Literal["raw"] = "raw"
|
||||
raw_id: str
|
||||
session_id: str
|
||||
display_state: Literal["excerpt", "no_user_excerpt"]
|
||||
|
||||
|
||||
class HomeMemoryLatest(_HomeLatest):
|
||||
type: Literal["memory"] = "memory"
|
||||
memory_id: str
|
||||
scene: Literal["chat", "working", "coding"]
|
||||
# scene=all lists chat rows and productivity experiences; facts are not
|
||||
# part of that page or its total.
|
||||
kind: Literal["chat", "experience"]
|
||||
variant: Literal["working", "coding"] | None
|
||||
|
||||
|
||||
class HomeProjectionV1(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
projection_version: Literal[1] = 1
|
||||
user_id: str
|
||||
effective_mode: MemoryMode
|
||||
capabilities: HomeProjectionCapabilities
|
||||
visible_count: int = Field(ge=0)
|
||||
display_state: ProjectionDisplayState
|
||||
latest: HomeRawLatest | HomeMemoryLatest | None
|
||||
observed_at: datetime
|
||||
|
||||
|
||||
def raw_list_filters(statuses: list[str] | tuple[str, ...] | None = None) -> dict[str, list[str]]:
|
||||
"""The one kept-raw definition shared by `/raw` and the home projection."""
|
||||
return {
|
||||
"layers": ["l1_raw"],
|
||||
"statuses": list(statuses or RAW_STATUSES),
|
||||
}
|
||||
|
||||
|
||||
def home_capabilities(mode: MemoryMode) -> HomeProjectionCapabilities:
|
||||
return HomeProjectionCapabilities(
|
||||
content_plane="raw" if mode == "lite" else "memory",
|
||||
generation_required=mode != "lite",
|
||||
graph_enabled=mode == "ultra",
|
||||
evolve_available=mode == "ultra",
|
||||
)
|
||||
|
||||
|
||||
def raw_user_excerpt(content: str, *, limit: int = HOME_EXCERPT_CODEPOINTS) -> tuple[str, bool]:
|
||||
"""Return only displayable user text from one SDK raw turn.
|
||||
|
||||
Role selection happens before wrapper removal, so assistant/system content
|
||||
can never become a homepage excerpt after sanitization.
|
||||
"""
|
||||
candidates: list[str] = []
|
||||
for role, text in raw_turn_messages(content):
|
||||
if role != "user":
|
||||
continue
|
||||
envelope = unwrap_host_envelope_with_positions(text)
|
||||
if envelope.status == "ambiguous":
|
||||
# An uncertain host envelope may contain system context. Showing
|
||||
# nothing is safer than guessing which bytes the person authored.
|
||||
continue
|
||||
cleaned = envelope.text or ""
|
||||
cleaned = _SPACE.sub(" ", cleaned).strip()
|
||||
if not cleaned or _CONTROL_ONLY.fullmatch(cleaned):
|
||||
continue
|
||||
candidates.append(cleaned)
|
||||
joined = " ".join(candidates)
|
||||
return joined[:limit], len(joined) > limit
|
||||
|
||||
|
||||
def _epoch(value: int | None) -> datetime | None:
|
||||
return datetime.fromtimestamp(value, tz=UTC) if value is not None else None
|
||||
|
||||
|
||||
def _raw_time(record: HySdkRawRecord) -> tuple[datetime | None, TimeBasis | None]:
|
||||
if record.memory_at is not None:
|
||||
return _epoch(record.memory_at), "conversation"
|
||||
if record.gmt_created is not None:
|
||||
return _epoch(record.gmt_created), "import"
|
||||
return None, None
|
||||
|
||||
|
||||
def _memory_time(record: HySdkMemoryRecord) -> tuple[datetime | None, TimeBasis | None]:
|
||||
# Exactly the scene=all merge key. `memory_at` is event time and does not
|
||||
# decide which row list_memories returns first.
|
||||
if record.sort_updated_at is not None:
|
||||
return _epoch(record.sort_updated_at), "update"
|
||||
written = record.gmt_created if record.scene == "chat" else record.created_at
|
||||
if written is not None:
|
||||
return _epoch(written), "write"
|
||||
return None, None
|
||||
|
||||
|
||||
def _bounded(text: str) -> tuple[str, bool]:
|
||||
flattened = _SPACE.sub(" ", text).strip()
|
||||
return flattened[:HOME_EXCERPT_CODEPOINTS], len(flattened) > HOME_EXCERPT_CODEPOINTS
|
||||
|
||||
|
||||
def project_raw_latest(records: tuple[HySdkRawRecord, ...]) -> HomeRawLatest | None:
|
||||
if not records:
|
||||
return None
|
||||
for record in records:
|
||||
excerpt, truncated = raw_user_excerpt(record.content)
|
||||
if excerpt:
|
||||
at, basis = _raw_time(record)
|
||||
return HomeRawLatest(
|
||||
raw_id=record.raw_id,
|
||||
session_id=record.session_id,
|
||||
display_state="excerpt",
|
||||
time=at,
|
||||
time_basis=basis,
|
||||
excerpt=excerpt,
|
||||
truncated=truncated,
|
||||
source_id=record.source,
|
||||
)
|
||||
newest = records[0]
|
||||
at, basis = _raw_time(newest)
|
||||
return HomeRawLatest(
|
||||
raw_id=newest.raw_id,
|
||||
session_id=newest.session_id,
|
||||
display_state="no_user_excerpt",
|
||||
time=at,
|
||||
time_basis=basis,
|
||||
excerpt="",
|
||||
truncated=False,
|
||||
source_id=newest.source,
|
||||
)
|
||||
|
||||
|
||||
def project_memory_latest(
|
||||
records: tuple[HySdkMemoryRecord, ...],
|
||||
) -> HomeMemoryLatest | None:
|
||||
record = next(
|
||||
(
|
||||
item
|
||||
for item in records
|
||||
if item.kind in {"chat", "experience"}
|
||||
and (
|
||||
(item.kind == "chat" and item.scene == "chat" and item.variant is None)
|
||||
or (
|
||||
item.kind == "experience"
|
||||
and item.scene in {"working", "coding"}
|
||||
and item.variant == item.scene
|
||||
)
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if record is None:
|
||||
return None
|
||||
excerpt, truncated = _bounded(record.content)
|
||||
at, basis = _memory_time(record)
|
||||
return HomeMemoryLatest(
|
||||
memory_id=record.memory_id,
|
||||
scene=record.scene,
|
||||
kind=cast(Literal["chat", "experience"], record.kind),
|
||||
variant=record.variant,
|
||||
time=at,
|
||||
time_basis=basis,
|
||||
excerpt=excerpt,
|
||||
truncated=truncated,
|
||||
source_id=record.source or "",
|
||||
)
|
||||
|
||||
|
||||
async def read_home_projection(
|
||||
client: Any,
|
||||
*,
|
||||
user_id: str,
|
||||
mode: MemoryMode,
|
||||
) -> HomeProjectionV1:
|
||||
"""Read one authoritative projection; any SDK/shape failure escapes."""
|
||||
client_mode = getattr(client, "mode", mode)
|
||||
if client_mode != mode:
|
||||
raise RuntimeError("home_projection_mode_mismatch")
|
||||
if mode == "lite":
|
||||
raw = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
user_id=user_id,
|
||||
**raw_list_filters(),
|
||||
limit=HOME_PROJECTION_CANDIDATES,
|
||||
offset=0,
|
||||
order="desc",
|
||||
)
|
||||
raw_page = HySdkRawPage.from_memory_list(raw, limit=HOME_PROJECTION_CANDIDATES, offset=0)
|
||||
visible_count = raw_page.total
|
||||
raw_latest = project_raw_latest(raw_page.raw)
|
||||
latest: HomeRawLatest | HomeMemoryLatest | None = raw_latest
|
||||
display_state: ProjectionDisplayState = (
|
||||
"empty"
|
||||
if visible_count == 0
|
||||
else raw_latest.display_state
|
||||
if raw_latest is not None
|
||||
else "no_user_excerpt"
|
||||
)
|
||||
else:
|
||||
raw = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
user_id=user_id,
|
||||
scene="all",
|
||||
statuses=["active"],
|
||||
limit=HOME_PROJECTION_CANDIDATES,
|
||||
offset=0,
|
||||
order="desc",
|
||||
)
|
||||
memory_page, _skipped = HySdkMemoryPage.from_sdk_skipping_invalid(
|
||||
raw, limit=HOME_PROJECTION_CANDIDATES, offset=0
|
||||
)
|
||||
visible_count = memory_page.total
|
||||
latest = project_memory_latest(memory_page.memories)
|
||||
display_state = (
|
||||
"empty"
|
||||
if visible_count == 0
|
||||
else "excerpt"
|
||||
if latest is not None
|
||||
else "no_displayable_memory"
|
||||
)
|
||||
return HomeProjectionV1(
|
||||
user_id=user_id,
|
||||
effective_mode=mode,
|
||||
capabilities=home_capabilities(mode),
|
||||
visible_count=visible_count,
|
||||
display_state=display_state,
|
||||
latest=latest,
|
||||
observed_at=datetime.now(UTC),
|
||||
)
|
||||
@@ -111,6 +111,7 @@
|
||||
| `test_health.py` | `contract` | 覆盖 a backfill that fails says so rather than disappearing、a failed catch up leaves a traceback behind、configured model without content consent does not start backfill 等 15 个场景。 |
|
||||
| `test_hermes_gateway_injection.py` | `contract` | Hermes gateway text in `user` rows: notices are skipped, relayed messages unwrapped. |
|
||||
| `test_history_listing_rotation.py` | `contract` | 覆盖 history listing alternates started and untouched files、history listing drains whichever cohort is longer 场景。 |
|
||||
| `test_home_projection.py` | `contract` | 覆盖 memory projection covers pro and ultra、memory projection preserves productivity namespace、project raw latest uses import time without conversation time 等 7 个场景。 |
|
||||
| `test_host_capabilities.py` | `unit` | A plugin does not become realtime on every host or every unknown version. |
|
||||
| `test_hourly_c1_peak_replay_contract.py` | `contract` | Guard the package peak-replay probe's error accounting. |
|
||||
| `test_hourly_c1_recall_stage_probe_contract.py` | `contract` | Guard the packaged recall stage probe's command and error semantics. |
|
||||
@@ -122,6 +123,7 @@
|
||||
| `test_import_api.py` | `integration` | 覆盖 resume during cleanup returns conflict without internal error、authorize detect import and revoke api flow、protected api rejects missing token and foreign origin 等 5 个场景。 |
|
||||
| `test_import_control_races_review.py` | `contract` | Read-only review repro: real runner/store, synthetic delayed adapter cleanup. |
|
||||
| `test_import_evidence_atomicity.py` | `contract` | 覆盖 counter failure rolls back evidence versions and cursor、same identity cursor rewind is blocked and logged、replaced identity may reset checkpoint 等 20 个场景。 |
|
||||
| `test_import_job_retention.py` | `integration` | `import_jobs` stays bounded, and the questions asked of it are SQL, not scans. |
|
||||
| `test_import_oversized_verbatim.py` | `contract` | 覆盖 normal verbatim is unchanged、oversized verbatim is truncated instead of rejected 场景。 |
|
||||
| `test_import_runner.py` | `contract` | 覆盖 job transitions are explicit、runner keeps per record errors and continues、sync slices reuse the same source listing 等 22 个场景。 |
|
||||
| `test_import_skip_batching.py` | `contract` | 覆盖 batch limit and tail use fewer commits、batched skips keep their order against the next write、flushes before every nonmatching event 等 14 个场景。 |
|
||||
@@ -190,6 +192,7 @@
|
||||
| `test_memory_write_registry.py` | `contract` | A3-01: acceptance and outcome recording for the unified write path. |
|
||||
| `test_memory_write_routes.py` | `integration` | A3-01: the HTTP write entry, tested against a real FastAPI app. |
|
||||
| `test_migrate_off_sqlite.py` | `contract` | A1-06: the migration is only done when reconciliation agrees. |
|
||||
| `test_model_backoff.py` | `contract` | A broken model configuration must not turn a backlog into a request storm (#245). |
|
||||
| `test_model_creation_api.py` | `integration` | Normal protected model creation routes with temporary data and in-memory secrets. |
|
||||
| `test_model_creation_operations.py` | `contract` | 覆盖 lookup without table is read only and legacy create does not initialize、operation commits with profile and version without autoactivation、any replay conflicts before secret put 等 21 个场景。 |
|
||||
| `test_model_creation_validation_boundary.py` | `integration` | Real ASGI validation must precede creation reservation and secret writes. |
|
||||
@@ -211,6 +214,7 @@
|
||||
| `test_onboarding_profile.py` | `integration` | R-09:首次安装引导把画像录下来,不问用户一句话。 |
|
||||
| `test_oversized_file_guard.py` | `contract` | One pass over a source file is bounded by a byte budget, not skipped. |
|
||||
| `test_parent_watchdog.py` | `integration` | A Core that outlives its launcher makes the next launch impossible. |
|
||||
| `test_pinned_sdk_raw_search.py` | `integration` | MemHub's raw search against the hy-memory wheel it pins, not the repo source. |
|
||||
| `test_policy_api_wiring.py` | `integration` | 覆盖 global model permission off does not backfill、backfill puts history on write jobs without scheduling memory tasks、source pause skips history until the source is reenabled 场景。 |
|
||||
| `test_policy_production_entry.py` | `contract` | 覆盖 production serve passes only supported app options、maintenance serve does not own the runtime 场景。 |
|
||||
| `test_precipitation_concurrency_setting.py` | `contract` | Rule 2/9: precipitation concurrency is configurable, not hardcoded to 1. |
|
||||
@@ -319,4 +323,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. |
|
||||
|
||||
共 314 个测试文件。
|
||||
共 318 个测试文件。
|
||||
@@ -14,8 +14,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
|
||||
EXPECTED_TEST_FILE_COUNT = 314
|
||||
EXPECTED_TEST_FILES_SHA256 = "d745df3dfb964a8d2197c1ca3768728249d84ac4729305155ae1ef48428a19e4"
|
||||
EXPECTED_TEST_FILE_COUNT = 318
|
||||
EXPECTED_TEST_FILES_SHA256 = "df8752566a0bf9e540f68b4948f3f5dbc409bd6836707b1330206e2349d70940"
|
||||
|
||||
# Pure transformations, validation models, parity checks and state machines.
|
||||
# This list is intentionally small: uncertain files default to contract.
|
||||
@@ -70,6 +70,7 @@ INTEGRATION_FILES = frozenset(
|
||||
"test_file_import_operations.py",
|
||||
"test_graph_width_endpoint.py",
|
||||
"test_import_api.py",
|
||||
"test_import_job_retention.py",
|
||||
"test_live_capture.py",
|
||||
"test_live_watch_grants.py",
|
||||
"test_log_privacy.py",
|
||||
@@ -93,6 +94,7 @@ INTEGRATION_FILES = frozenset(
|
||||
"test_model_profile_context_reset.py",
|
||||
"test_onboarding_profile.py",
|
||||
"test_parent_watchdog.py",
|
||||
"test_pinned_sdk_raw_search.py",
|
||||
"test_policy_api_wiring.py",
|
||||
"test_recall_scope_control.py",
|
||||
"test_retrieval_lifespan.py",
|
||||
|
||||
@@ -324,3 +324,48 @@ def test_saturated_raw_reads_do_not_delay_memory_reads() -> None:
|
||||
finally:
|
||||
release_raw.set()
|
||||
shutdown_bulk_pool()
|
||||
|
||||
|
||||
def test_recall_reads_answer_while_every_sdk_write_thread_is_stuck() -> None:
|
||||
"""A write that hangs in the SDK keeps its thread, not recall's (#231)."""
|
||||
from one_memory.background_work import SDK_WRITE_WORKERS, in_sdk_read, in_sdk_write
|
||||
|
||||
stuck = threading.Barrier(SDK_WRITE_WORKERS + 1)
|
||||
release = threading.Event()
|
||||
|
||||
def hung_write() -> None:
|
||||
stuck.wait(timeout=2)
|
||||
assert release.wait(5)
|
||||
|
||||
async def scenario() -> tuple[float, int]:
|
||||
writes = [asyncio.create_task(in_sdk_write(hung_write)) for _ in range(SDK_WRITE_WORKERS)]
|
||||
# One more than the pool holds: it waits for a write thread, as the
|
||||
# drain's next write would.
|
||||
queued = asyncio.create_task(in_sdk_write(lambda: None))
|
||||
await asyncio.to_thread(stuck.wait, 2)
|
||||
started = time.monotonic()
|
||||
answer = await asyncio.wait_for(in_sdk_read(lambda: 7), 1)
|
||||
elapsed = time.monotonic() - started
|
||||
assert not queued.done()
|
||||
release.set()
|
||||
await asyncio.gather(*writes, queued)
|
||||
return elapsed, answer
|
||||
|
||||
try:
|
||||
elapsed, answer = asyncio.run(scenario())
|
||||
assert answer == 7
|
||||
assert elapsed < 0.2
|
||||
finally:
|
||||
release.set()
|
||||
shutdown_bulk_pool()
|
||||
|
||||
|
||||
def test_sdk_writes_run_on_their_own_bounded_threads() -> None:
|
||||
from one_memory.background_work import SDK_WRITE_WORKERS, in_sdk_write, sdk_write_pool
|
||||
|
||||
try:
|
||||
name = asyncio.run(in_sdk_write(lambda: threading.current_thread().name))
|
||||
assert name.startswith("one-memory-sdk-write")
|
||||
assert sdk_write_pool()._max_workers == SDK_WRITE_WORKERS
|
||||
finally:
|
||||
shutdown_bulk_pool()
|
||||
@@ -263,6 +263,48 @@ def test_a_switch_over_the_api_pauses_writes_and_keeps_intake(tmp_path, backend)
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_graph_backfill_blocks_starting_an_embedding_switch(tmp_path, backend):
|
||||
backend.store.put(
|
||||
builtin_base(),
|
||||
512,
|
||||
{"m1": ("memory", "local")},
|
||||
model="bge-small-zh-v1.5",
|
||||
)
|
||||
|
||||
async def scenario():
|
||||
app = application(tmp_path)
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
await recovered(client)
|
||||
profile = remote_profile(app)
|
||||
plan = (
|
||||
await client.post(
|
||||
"/api/v1/embedding-switches/plan",
|
||||
json={"profile_id": profile.profile_id},
|
||||
)
|
||||
).json()
|
||||
hold = asyncio.Event()
|
||||
task = asyncio.create_task(hold.wait())
|
||||
app.state.memory_graph_backfill._task = task
|
||||
try:
|
||||
response = await client.post(
|
||||
"/api/v1/embedding-switches",
|
||||
json={
|
||||
"profile_id": profile.profile_id,
|
||||
"expected_revision": profile.revision,
|
||||
"plan_token": plan["plan_token"],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == "memory_lifecycle_busy"
|
||||
assert app.state.embedding_switch.writes_open() is True
|
||||
finally:
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
app.state.memory_graph_backfill._task = None
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_cancel_over_the_api(tmp_path, backend):
|
||||
backend.store.put(builtin_base(), 512, {f"m{i}": (f"m {i}", "local") for i in range(4)})
|
||||
|
||||
|
||||
@@ -150,7 +150,18 @@ def test_lifespan_content_backfill_failure_is_visible(tmp_path, monkeypatch) ->
|
||||
|
||||
async def scenario() -> None:
|
||||
async with app.router.lifespan_context(app):
|
||||
health = await _get_json(app, "/api/v1/health")
|
||||
# The rebuild is released when post-ready recovery finishes, and
|
||||
# recovery now resumes imports off the loop, so it can still be
|
||||
# running at the first request.
|
||||
for _ in range(500):
|
||||
if app.state.recovery in {"done", "failed"}:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
for _ in range(500):
|
||||
health = await _get_json(app, "/api/v1/health")
|
||||
if health["content_candidate_backfill"] != "running":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert health["content_candidate_backfill"] == "failed"
|
||||
assert health["content_candidate_backfill_failure"] == "database_locked"
|
||||
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from one_memory.api import create_app
|
||||
from one_memory.config import Settings
|
||||
from one_memory.hy_sdk_types import HySdkReadError
|
||||
from one_memory.ui_projection import (
|
||||
HOME_PROJECTION_CANDIDATES,
|
||||
project_raw_latest,
|
||||
raw_user_excerpt,
|
||||
read_home_projection,
|
||||
)
|
||||
|
||||
|
||||
def raw_row(
|
||||
raw_id: str,
|
||||
content: str,
|
||||
*,
|
||||
status: str = "active",
|
||||
memory_at: int | None = 1_700_000_000,
|
||||
created: int | None = 1_699_000_000,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"memory_id": raw_id,
|
||||
"content": content,
|
||||
"layer": "l1_raw",
|
||||
"status": status,
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"session_id": f"session-{raw_id}",
|
||||
"source": "cursor",
|
||||
"memory_at": memory_at,
|
||||
"gmt_created": created,
|
||||
"custom": {"private": "not projected"},
|
||||
}
|
||||
|
||||
|
||||
def chat_row(memory_id: str = "m-1") -> dict[str, object]:
|
||||
return {
|
||||
"memory_id": memory_id,
|
||||
"content": "The user ships on Friday",
|
||||
"layer": "l2_fact",
|
||||
"status": "active",
|
||||
"is_latest": True,
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"session_id": "s-1",
|
||||
"source": "cursor",
|
||||
"memory_at": 1_700_000_000,
|
||||
"gmt_created": 1_699_000_000,
|
||||
"updated_at": 1_800_000_000,
|
||||
"scene": "chat",
|
||||
"kind": "chat",
|
||||
}
|
||||
|
||||
|
||||
class ProjectionSdk:
|
||||
def __init__(self, rows: list[dict[str, object]], *, total: int | None = None) -> None:
|
||||
self.rows = rows
|
||||
self.total = len(rows) if total is None else total
|
||||
self.calls: list[dict[str, object]] = []
|
||||
|
||||
def list_memories(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
if kwargs.get("layers") == ["l1_raw"]:
|
||||
return {"vdb": {"memories": self.rows, "total": self.total}}
|
||||
return {
|
||||
"scene": "all",
|
||||
"memories": self.rows,
|
||||
"total": self.total,
|
||||
"totals": {"chat": len(self.rows), "working": 0, "coding": 0},
|
||||
"errors": {},
|
||||
}
|
||||
|
||||
def memory_stats(self, **kwargs):
|
||||
raise AssertionError("home projection must use one scene=all snapshot")
|
||||
|
||||
|
||||
def test_raw_excerpt_keeps_only_user_query_and_truncates_by_codepoint() -> None:
|
||||
text = (
|
||||
"[user]: <system_reminder>ignore this</system_reminder>\n"
|
||||
"<user_info>private host data</user_info>\n"
|
||||
"<user_query>短句 https://example.com/a /tmp/x " + "🙂" * 220 + "</user_query>\n"
|
||||
"[assistant]: secret answer"
|
||||
)
|
||||
excerpt, truncated = raw_user_excerpt(text)
|
||||
assert excerpt.startswith("短句 https://example.com/a /tmp/x ")
|
||||
assert "ignore" not in excerpt and "private" not in excerpt and "secret" not in excerpt
|
||||
assert len(excerpt) == 200
|
||||
assert truncated is True
|
||||
|
||||
|
||||
def test_raw_excerpt_fails_closed_on_nested_host_wrappers() -> None:
|
||||
assert raw_user_excerpt(
|
||||
"[user]: <system_reminder><user_query>keep me</user_query></system_reminder>"
|
||||
) == ("", False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"wrapper",
|
||||
[
|
||||
"system_reminder",
|
||||
"system-reminder",
|
||||
"user_info",
|
||||
"attached_files",
|
||||
"image_local_path",
|
||||
"agent_transcripts",
|
||||
"rules",
|
||||
"additional_data",
|
||||
"working_memory_reminder",
|
||||
],
|
||||
)
|
||||
def test_raw_excerpt_uses_shared_envelope_parser_for_host_context(wrapper) -> None:
|
||||
excerpt, truncated = raw_user_excerpt(
|
||||
f"[user]: <{wrapper}>private</{wrapper}><user_query>keep me</user_query>"
|
||||
)
|
||||
assert (excerpt, truncated) == ("keep me", False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"[user]: <system_reminder>unclosed <user_query>do not guess</user_query>",
|
||||
"[user]: <user_query>unclosed",
|
||||
"[user]: <rules><rules>nested</rules><user_query>ambiguous</user_query></rules>",
|
||||
],
|
||||
)
|
||||
def test_raw_excerpt_fails_closed_on_ambiguous_envelopes(text) -> None:
|
||||
assert raw_user_excerpt(text) == ("", False)
|
||||
|
||||
|
||||
def test_raw_projection_uses_shadow_rows_and_k10_without_leaking_payload() -> None:
|
||||
sdk = ProjectionSdk(
|
||||
[
|
||||
raw_row("r-1", "[assistant]: no user here", status="shadow"),
|
||||
raw_row("r-2", "[user]: deploy Friday", status="active"),
|
||||
],
|
||||
total=42,
|
||||
)
|
||||
result = asyncio.run(read_home_projection(sdk, user_id="local", mode="lite"))
|
||||
assert result.visible_count == 42
|
||||
assert result.latest is not None and result.latest.type == "raw"
|
||||
assert result.latest.raw_id == "r-2"
|
||||
assert result.latest.excerpt == "deploy Friday"
|
||||
assert result.latest.time_basis == "conversation"
|
||||
assert result.display_state == "excerpt"
|
||||
assert sdk.calls == [
|
||||
{
|
||||
"user_id": "local",
|
||||
"layers": ["l1_raw"],
|
||||
"statuses": ["active", "shadow"],
|
||||
"limit": HOME_PROJECTION_CANDIDATES,
|
||||
"offset": 0,
|
||||
"order": "desc",
|
||||
}
|
||||
]
|
||||
assert set(result.latest.model_dump()) == {
|
||||
"type",
|
||||
"raw_id",
|
||||
"session_id",
|
||||
"display_state",
|
||||
"time",
|
||||
"time_basis",
|
||||
"excerpt",
|
||||
"truncated",
|
||||
"source_id",
|
||||
}
|
||||
serialized = result.model_dump(mode="json")
|
||||
assert serialized["projection_version"] == 1
|
||||
assert serialized["latest"]["time"].endswith("Z")
|
||||
assert "content" not in serialized["latest"]
|
||||
|
||||
|
||||
def test_raw_projection_reports_no_user_excerpt_when_k_has_none() -> None:
|
||||
sdk = ProjectionSdk([raw_row("r-1", "[assistant]: answer")])
|
||||
result = asyncio.run(read_home_projection(sdk, user_id="local", mode="lite"))
|
||||
assert result.latest is not None and result.latest.type == "raw"
|
||||
assert result.latest.raw_id == "r-1"
|
||||
assert result.latest.display_state == "no_user_excerpt"
|
||||
assert result.latest.excerpt == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "graph", "evolve"),
|
||||
[("pro", False, False), ("ultra", True, True)],
|
||||
)
|
||||
def test_memory_projection_covers_pro_and_ultra(mode, graph, evolve) -> None:
|
||||
sdk = ProjectionSdk([chat_row()], total=9)
|
||||
result = asyncio.run(read_home_projection(sdk, user_id="local", mode=mode))
|
||||
assert result.visible_count == 9
|
||||
assert result.display_state == "excerpt"
|
||||
assert result.capabilities.content_plane == "memory"
|
||||
assert result.capabilities.graph_enabled is graph
|
||||
assert result.capabilities.evolve_available is evolve
|
||||
assert result.latest is not None and result.latest.type == "memory"
|
||||
assert result.latest.memory_id == "m-1"
|
||||
assert result.latest.time_basis == "update"
|
||||
assert result.latest.time.isoformat() == "2027-01-15T08:00:00+00:00"
|
||||
|
||||
|
||||
def test_chat_projection_prefers_gmt_modified_when_the_sdk_supplies_it() -> None:
|
||||
row = {**chat_row(), "gmt_modified": 1_810_000_000}
|
||||
result = asyncio.run(read_home_projection(ProjectionSdk([row]), user_id="local", mode="pro"))
|
||||
assert result.latest is not None
|
||||
assert result.latest.time_basis == "update"
|
||||
assert result.latest.time.timestamp() == 1_810_000_000
|
||||
|
||||
|
||||
def test_memory_projection_uses_productivity_updated_at() -> None:
|
||||
row = {
|
||||
"kind": "experience",
|
||||
"experience_id": "e-1",
|
||||
"task": "Release",
|
||||
"solution": "Run tests",
|
||||
"scene": "working",
|
||||
"status": "active",
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"source": "manual",
|
||||
"memory_at": 1_600_000_000,
|
||||
"created_at": 1_700_000_000,
|
||||
"updated_at": 1_710_000_000,
|
||||
}
|
||||
result = asyncio.run(read_home_projection(ProjectionSdk([row]), user_id="local", mode="pro"))
|
||||
assert result.latest is not None and result.latest.type == "memory"
|
||||
assert (result.latest.scene, result.latest.kind, result.latest.variant) == (
|
||||
"working",
|
||||
"experience",
|
||||
"working",
|
||||
)
|
||||
assert result.latest.time_basis == "update"
|
||||
assert result.latest.time.isoformat() == "2024-03-09T16:00:00+00:00"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"row",
|
||||
[
|
||||
{**chat_row(), "updated_at": None},
|
||||
{
|
||||
"kind": "experience",
|
||||
"experience_id": "e-1",
|
||||
"task": "Release",
|
||||
"solution": "Run tests",
|
||||
"scene": "coding",
|
||||
"status": "active",
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"source": "manual",
|
||||
"created_at": 1_700_000_000,
|
||||
"updated_at": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_memory_projection_sort_time_falls_back_to_scene_creation(row) -> None:
|
||||
result = asyncio.run(read_home_projection(ProjectionSdk([row]), user_id="local", mode="pro"))
|
||||
assert result.latest is not None
|
||||
assert result.latest.time_basis == "write"
|
||||
expected = row.get("gmt_created") or row.get("created_at")
|
||||
assert result.latest.time.timestamp() == expected
|
||||
|
||||
|
||||
def test_fact_is_not_a_home_scene_all_item() -> None:
|
||||
row = {
|
||||
"kind": "fact",
|
||||
"fact_id": "f-1",
|
||||
"statement": "Uses pytest",
|
||||
"scene": "coding",
|
||||
"status": "active",
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"source": "manual",
|
||||
"created_at": 1_700_000_000,
|
||||
}
|
||||
result = asyncio.run(read_home_projection(ProjectionSdk([row]), user_id="local", mode="pro"))
|
||||
assert result.visible_count == 1
|
||||
assert result.latest is None
|
||||
assert result.display_state == "no_displayable_memory"
|
||||
|
||||
|
||||
def test_invalid_latest_uses_next_parseable_candidate_from_same_snapshot() -> None:
|
||||
sdk = ProjectionSdk(
|
||||
[
|
||||
{"memory_id": "broken", "content": "", "scene": "chat", "kind": "chat"},
|
||||
chat_row("m-2"),
|
||||
],
|
||||
total=2,
|
||||
)
|
||||
result = asyncio.run(read_home_projection(sdk, user_id="local", mode="pro"))
|
||||
assert result.visible_count == 2
|
||||
assert result.latest is not None and result.latest.memory_id == "m-2"
|
||||
assert len(sdk.calls) == 1
|
||||
|
||||
|
||||
def test_scene_all_rejects_boolean_total() -> None:
|
||||
sdk = ProjectionSdk([chat_row()])
|
||||
sdk.total = True # type: ignore[assignment]
|
||||
with pytest.raises(HySdkReadError):
|
||||
asyncio.run(read_home_projection(sdk, user_id="local", mode="pro"))
|
||||
|
||||
|
||||
def test_projection_read_failure_is_not_converted_to_an_empty_projection() -> None:
|
||||
class Broken:
|
||||
def list_memories(self, **kwargs):
|
||||
raise OSError("down")
|
||||
|
||||
with pytest.raises(OSError, match="down"):
|
||||
asyncio.run(read_home_projection(Broken(), user_id="local", mode="lite"))
|
||||
|
||||
|
||||
def test_projection_refuses_a_client_built_for_another_mode() -> None:
|
||||
sdk = ProjectionSdk([chat_row()])
|
||||
sdk.mode = "lite"
|
||||
with pytest.raises(RuntimeError, match="home_projection_mode_mismatch"):
|
||||
asyncio.run(read_home_projection(sdk, user_id="local", mode="pro"))
|
||||
|
||||
|
||||
def test_home_projection_route_maps_whole_read_failure_to_503(tmp_path, monkeypatch) -> None:
|
||||
class Broken:
|
||||
def list_memories(self, **kwargs):
|
||||
raise OSError("down")
|
||||
|
||||
class Handle:
|
||||
async def client(self):
|
||||
return Broken()
|
||||
|
||||
app = create_app(
|
||||
Settings(
|
||||
_env_file=None,
|
||||
data_dir=tmp_path / "data",
|
||||
source_home=tmp_path,
|
||||
api_token=SecretStr("test"),
|
||||
imports_enabled=False,
|
||||
automatic_memory_processing=False,
|
||||
live_sync_enabled=False,
|
||||
),
|
||||
source_catalog={},
|
||||
)
|
||||
monkeypatch.setattr("one_memory.api._sdk_client_handle", lambda _app: Handle())
|
||||
with TestClient(app, headers={"Authorization": "Bearer test"}) as client:
|
||||
response = client.get("/api/v2/home-projection")
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"detail": "home_projection_read_failed"}
|
||||
|
||||
|
||||
def test_home_projection_reads_mode_once_and_matches_the_client(tmp_path, monkeypatch) -> None:
|
||||
sdk = ProjectionSdk([chat_row()])
|
||||
sdk.mode = "pro"
|
||||
|
||||
class Handle:
|
||||
async def client(self):
|
||||
return sdk
|
||||
|
||||
app = create_app(
|
||||
Settings(
|
||||
_env_file=None,
|
||||
data_dir=tmp_path / "data",
|
||||
source_home=tmp_path,
|
||||
api_token=SecretStr("test"),
|
||||
imports_enabled=False,
|
||||
automatic_memory_processing=False,
|
||||
live_sync_enabled=False,
|
||||
),
|
||||
source_catalog={},
|
||||
)
|
||||
reads = 0
|
||||
|
||||
def mode(_app):
|
||||
nonlocal reads
|
||||
reads += 1
|
||||
return "pro"
|
||||
|
||||
with TestClient(app, headers={"Authorization": "Bearer test"}) as client:
|
||||
monkeypatch.setattr("one_memory.api._memory_mode", mode)
|
||||
monkeypatch.setattr("one_memory.api._sdk_client_handle", lambda _app: Handle())
|
||||
response = client.get("/api/v2/home-projection")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["effective_mode"] == "pro"
|
||||
assert reads == 1
|
||||
|
||||
|
||||
def test_project_raw_latest_uses_import_time_without_conversation_time() -> None:
|
||||
from one_memory.hy_sdk_types import HySdkRawRecord
|
||||
|
||||
record = HySdkRawRecord.from_sdk(
|
||||
{
|
||||
**raw_row("r-1", "[user]: hello", memory_at=None),
|
||||
"raw_id": "r-1",
|
||||
}
|
||||
)
|
||||
latest = project_raw_latest((record,))
|
||||
assert latest is not None
|
||||
assert latest.time_basis == "import"
|
||||
@@ -34,10 +34,10 @@ def test_resume_during_cleanup_returns_conflict_without_internal_error(tmp_path,
|
||||
job = supervisor.job_store.create(user_id="local", source_id="fixture")
|
||||
supervisor.job_store.transition(job.job_id, ImportStatus.PAUSED)
|
||||
|
||||
def draining(_job_id):
|
||||
async def draining(_job_id):
|
||||
raise InvalidJobTransitionError("import_cleanup_in_progress")
|
||||
|
||||
monkeypatch.setattr(supervisor, "resume", draining)
|
||||
monkeypatch.setattr(supervisor, "resume_off_loop", draining)
|
||||
response = await client.post(f"/api/v1/import-jobs/{job.job_id}/resume")
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {"detail": "invalid_job_transition"}
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
"""`import_jobs` stays bounded, and the questions asked of it are SQL, not scans.
|
||||
|
||||
Live sync opened a job per change it imported and nothing ever removed one, so
|
||||
the table only grew; every caller then read all of it and filtered in Python.
|
||||
These tests hold the three halves of the fix: what pruning keeps, that the
|
||||
filtered queries answer exactly what the old filters did, and that the indexes
|
||||
behind them arrive by migration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from one_memory.api import _prune_finished_import_jobs, create_app
|
||||
from one_memory.config import Settings
|
||||
from one_memory.imports.jobs import (
|
||||
FINISHED_STATUSES,
|
||||
SYNC_JOBS_KEPT_PER_SOURCE,
|
||||
ImportJob,
|
||||
ImportJobStore,
|
||||
ImportStatus,
|
||||
)
|
||||
from one_memory.memory_server.migration_runner import SqliteMigrationStore, migrate
|
||||
from one_memory.memory_server.migrations import (
|
||||
IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION,
|
||||
MIGRATIONS,
|
||||
)
|
||||
|
||||
_BASE = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
|
||||
|
||||
def _store(tmp_path) -> ImportJobStore:
|
||||
return ImportJobStore(tmp_path / "one-memory.sqlite3")
|
||||
|
||||
|
||||
def _job(
|
||||
store: ImportJobStore,
|
||||
*,
|
||||
user_id: str = "local",
|
||||
source_id: str = "fixture",
|
||||
operation: str = "sync",
|
||||
status: ImportStatus = ImportStatus.COMPLETED,
|
||||
minute: int,
|
||||
issue: bool = False,
|
||||
) -> str:
|
||||
"""Insert one row as its terminal shape, at a chosen creation time."""
|
||||
created = (_BASE + timedelta(minutes=minute)).isoformat()
|
||||
job_id = f"{source_id}-{user_id}-{operation}-{minute:05d}"
|
||||
with store._connect(writing=True) as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO import_jobs (job_id,user_id,source_id,status,created_at,updated_at,"
|
||||
"operation) VALUES (?,?,?,?,?,?,?)",
|
||||
(job_id, user_id, source_id, status, created, created, operation),
|
||||
)
|
||||
if issue:
|
||||
connection.execute(
|
||||
"INSERT INTO import_issues (job_id,kind,conversation_id,reason,created_at) "
|
||||
"VALUES (?,'error',NULL,'synthetic_reason',?)",
|
||||
(job_id, created),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO import_skip_reason_counts (job_id,reason,count) VALUES (?,'r',1)",
|
||||
(job_id,),
|
||||
)
|
||||
return job_id
|
||||
|
||||
|
||||
def _ids(store: ImportJobStore, **filters) -> list[str]:
|
||||
return [job.job_id for job in store.find_jobs(**filters)]
|
||||
|
||||
|
||||
def _count(store: ImportJobStore, table: str, job_id: str) -> int:
|
||||
with store._connect() as connection:
|
||||
return int(
|
||||
connection.execute(
|
||||
f"SELECT COUNT(*) FROM {table} WHERE job_id=?", (job_id,)
|
||||
).fetchone()[0]
|
||||
)
|
||||
|
||||
|
||||
def test_pruning_keeps_the_newest_finished_syncs_of_every_source(tmp_path) -> None:
|
||||
store = _store(tmp_path)
|
||||
keep = 5
|
||||
for source in ("alpha", "beta"):
|
||||
for minute in range(12):
|
||||
status = ImportStatus.CANCELLED if minute % 4 == 0 else ImportStatus.COMPLETED
|
||||
_job(store, source_id=source, minute=minute, status=status, issue=True)
|
||||
# A source with fewer than `keep` keeps all of them.
|
||||
for minute in range(3):
|
||||
_job(store, source_id="gamma", minute=minute)
|
||||
# Never pruned: the active sync, history of any state, another user's lane.
|
||||
running = _job(store, source_id="alpha", minute=1000, status=ImportStatus.RUNNING)
|
||||
failed_history = _job(
|
||||
store, source_id="alpha", operation="history", minute=2, status=ImportStatus.FAILED
|
||||
)
|
||||
histories = [
|
||||
_job(store, source_id="alpha", operation="history", minute=100 + minute)
|
||||
for minute in range(10)
|
||||
]
|
||||
other_user = [_job(store, user_id="other", source_id="alpha", minute=m) for m in range(8)]
|
||||
|
||||
removed = store.prune_finished_sync_jobs(keep=keep)
|
||||
|
||||
assert removed == (12 - keep) * 2 + (8 - keep)
|
||||
for source in ("alpha", "beta"):
|
||||
finished = _ids(
|
||||
store, user_id="local", source_id=source, operation="sync", statuses=FINISHED_STATUSES
|
||||
)
|
||||
assert finished == [f"{source}-local-sync-{m:05d}" for m in range(12 - keep, 12)]
|
||||
# Their issues and skip counts went with them; the kept ones still have theirs.
|
||||
assert _count(store, "import_issues", f"{source}-local-sync-00000") == 0
|
||||
assert _count(store, "import_skip_reason_counts", f"{source}-local-sync-00000") == 0
|
||||
assert _count(store, "import_issues", f"{source}-local-sync-00011") == 1
|
||||
assert len(_ids(store, source_id="gamma")) == 3
|
||||
assert store.require(running).status is ImportStatus.RUNNING
|
||||
assert store.require(failed_history).status is ImportStatus.FAILED
|
||||
assert all(store.get(job_id) is not None for job_id in histories)
|
||||
assert _ids(store, user_id="other") == other_user[-keep:]
|
||||
# The newest job of every lane, which the App and live sync health show,
|
||||
# is always kept.
|
||||
assert store.latest_job(user_id="local", source_id="beta").job_id == "beta-local-sync-00011"
|
||||
|
||||
before = _ids(store)
|
||||
assert store.prune_finished_sync_jobs(keep=keep) == 0
|
||||
assert _ids(store) == before
|
||||
|
||||
|
||||
def test_pruning_is_bounded_per_call_and_finishes_when_repeated(tmp_path) -> None:
|
||||
store = _store(tmp_path)
|
||||
for minute in range(60):
|
||||
_job(store, minute=minute)
|
||||
|
||||
passes = []
|
||||
while True:
|
||||
removed = store.prune_finished_sync_jobs(keep=10, max_rows=15)
|
||||
passes.append(removed)
|
||||
if removed < 15:
|
||||
break
|
||||
|
||||
assert passes == [15, 15, 15, 5]
|
||||
assert len(_ids(store)) == 10
|
||||
|
||||
|
||||
def test_the_default_keep_count_is_twenty(tmp_path) -> None:
|
||||
store = _store(tmp_path)
|
||||
for minute in range(SYNC_JOBS_KEPT_PER_SOURCE + 7):
|
||||
_job(store, minute=minute)
|
||||
assert store.prune_finished_sync_jobs() == 7
|
||||
assert len(_ids(store)) == SYNC_JOBS_KEPT_PER_SOURCE == 20
|
||||
|
||||
|
||||
def test_startup_pruning_runs_to_completion_off_the_loop(tmp_path) -> None:
|
||||
store = _store(tmp_path)
|
||||
for minute in range(500):
|
||||
_job(store, minute=minute)
|
||||
|
||||
async def scenario() -> None:
|
||||
task = asyncio.create_task(_prune_finished_import_jobs(store, interval_seconds=3600))
|
||||
for _ in range(500):
|
||||
await asyncio.sleep(0.01)
|
||||
if len(await asyncio.to_thread(_ids, store)) == SYNC_JOBS_KEPT_PER_SOURCE:
|
||||
break
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert len(_ids(store)) == SYNC_JOBS_KEPT_PER_SOURCE
|
||||
|
||||
|
||||
def test_filtered_queries_answer_what_the_python_filters_did(tmp_path) -> None:
|
||||
store = _store(tmp_path)
|
||||
rng = random.Random(7)
|
||||
lanes = [(user, source) for user in ("local", "other") for source in ("a", "b", "c")]
|
||||
for minute in range(240):
|
||||
user, source = rng.choice(lanes)
|
||||
_job(
|
||||
store,
|
||||
user_id=user,
|
||||
source_id=source,
|
||||
operation=rng.choice(("sync", "history")),
|
||||
# Only terminal shapes: the active-slot index allows one active job per lane.
|
||||
status=rng.choice(FINISHED_STATUSES),
|
||||
minute=minute,
|
||||
)
|
||||
everything: list[ImportJob] = store.list_jobs()
|
||||
assert len(everything) == 240
|
||||
|
||||
status_sets = [
|
||||
None,
|
||||
(ImportStatus.COMPLETED,),
|
||||
(ImportStatus.CANCELLED, ImportStatus.COMPLETED),
|
||||
(ImportStatus.RUNNING,),
|
||||
]
|
||||
for user_id in (None, "local", "other"):
|
||||
for source_id in (None, "a", "c"):
|
||||
for operation in (None, "sync", "history"):
|
||||
for statuses in status_sets:
|
||||
expected = [
|
||||
job
|
||||
for job in everything
|
||||
if (user_id is None or job.user_id == user_id)
|
||||
and (source_id is None or job.source_id == source_id)
|
||||
and (operation is None or job.operation == operation)
|
||||
and (statuses is None or job.status in statuses)
|
||||
]
|
||||
found = store.find_jobs(
|
||||
user_id=user_id,
|
||||
source_id=source_id,
|
||||
operation=operation,
|
||||
statuses=statuses,
|
||||
)
|
||||
assert found == expected
|
||||
newest = store.find_jobs(
|
||||
user_id=user_id,
|
||||
source_id=source_id,
|
||||
operation=operation,
|
||||
statuses=statuses,
|
||||
newest_first=True,
|
||||
limit=1,
|
||||
)
|
||||
assert newest == (
|
||||
[max(expected, key=lambda job: (job.created_at, job.job_id))]
|
||||
if expected
|
||||
else []
|
||||
)
|
||||
for status in ImportStatus:
|
||||
assert store.list_jobs(status=status) == [job for job in everything if job.status is status]
|
||||
assert store.find_jobs(statuses=()) == []
|
||||
|
||||
|
||||
def test_the_lookup_indexes_arrive_by_migration_and_serve_the_lane_query(tmp_path) -> None:
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
older = tuple(
|
||||
item for item in MIGRATIONS if item.version < IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION
|
||||
)
|
||||
asyncio.run(migrate(SqliteMigrationStore(path), older))
|
||||
with sqlite3.connect(path) as database:
|
||||
before = {
|
||||
row[0] for row in database.execute("SELECT name FROM sqlite_master WHERE type='index'")
|
||||
}
|
||||
assert "import_jobs_lane" not in before
|
||||
|
||||
result = asyncio.run(migrate(SqliteMigrationStore(path), MIGRATIONS))
|
||||
|
||||
assert result.applied == (IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION,)
|
||||
with sqlite3.connect(path) as database:
|
||||
indexes = {
|
||||
row[0] for row in database.execute("SELECT name FROM sqlite_master WHERE type='index'")
|
||||
}
|
||||
plan = " ".join(
|
||||
str(row[-1])
|
||||
for row in database.execute(
|
||||
"EXPLAIN QUERY PLAN SELECT * FROM import_jobs "
|
||||
"WHERE user_id=? AND source_id=? AND operation=? AND status IN (?,?) "
|
||||
"ORDER BY created_at DESC LIMIT 1",
|
||||
("local", "fixture", "sync", "pending", "running"),
|
||||
)
|
||||
)
|
||||
assert {"import_jobs_lane", "import_jobs_status"} <= indexes
|
||||
assert "import_jobs_lane" in plan
|
||||
# Re-running it is a no-op, and a store opened on the database agrees.
|
||||
asyncio.run(migrate(SqliteMigrationStore(path), MIGRATIONS))
|
||||
ImportJobStore(path)
|
||||
|
||||
|
||||
def test_a_running_home_gets_the_indexes_from_the_store(tmp_path) -> None:
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
ImportJobStore(path)
|
||||
with sqlite3.connect(path) as database:
|
||||
database.execute("DROP INDEX import_jobs_lane")
|
||||
database.execute("DROP INDEX import_jobs_status")
|
||||
ImportJobStore(path)
|
||||
with sqlite3.connect(path) as database:
|
||||
indexes = {
|
||||
row[0] for row in database.execute("SELECT name FROM sqlite_master WHERE type='index'")
|
||||
}
|
||||
assert {"import_jobs_lane", "import_jobs_status"} <= indexes
|
||||
|
||||
|
||||
def test_the_mysql_rendering_tolerates_an_index_already_there() -> None:
|
||||
class Cursor:
|
||||
dialect = "mysql"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.attempted: list[str] = []
|
||||
|
||||
async def execute(self, statement: str) -> None:
|
||||
self.attempted.append(statement)
|
||||
raise RuntimeError(1061, "duplicate key name")
|
||||
|
||||
cursor = Cursor()
|
||||
migration = next(
|
||||
item for item in MIGRATIONS if item.version == IMPORT_JOB_LOOKUP_INDEXES_MIGRATION_VERSION
|
||||
)
|
||||
asyncio.run(migration.apply(cursor))
|
||||
assert cursor.attempted and "import_jobs" in cursor.attempted[0]
|
||||
|
||||
|
||||
def test_the_import_jobs_route_keeps_its_shape_and_order(tmp_path) -> None:
|
||||
async def scenario() -> None:
|
||||
app = create_app(
|
||||
Settings(
|
||||
data_dir=tmp_path / "data",
|
||||
source_home=tmp_path / "home",
|
||||
api_token=SecretStr("synthetic"),
|
||||
automatic_memory_processing=False,
|
||||
live_sync_enabled=False,
|
||||
)
|
||||
)
|
||||
async with (
|
||||
app.router.lifespan_context(app),
|
||||
AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": "Bearer synthetic"},
|
||||
) as client,
|
||||
):
|
||||
store: ImportJobStore = app.state.import_job_store
|
||||
for minute in (3, 1, 2):
|
||||
_job(store, source_id="fixture", minute=minute)
|
||||
_job(store, source_id="fixture", operation="history", minute=0)
|
||||
_job(store, user_id="other", source_id="hermes", minute=4)
|
||||
|
||||
response = await client.get("/api/v1/import-jobs")
|
||||
other = await client.get("/api/v1/import-jobs", params={"user_id": "other"})
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
expected = [job for job in store.list_jobs() if job.user_id == "local"]
|
||||
assert [item["job_id"] for item in body] == [job.job_id for job in expected]
|
||||
assert [item["created_at"] for item in body] == sorted(
|
||||
item["created_at"] for item in body
|
||||
)
|
||||
assert set(body[0]) == set(ImportJob.model_fields)
|
||||
assert [item["user_id"] for item in other.json()] == ["other"]
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_memory_processing_reports_the_write_drain(tmp_path) -> None:
|
||||
async def scenario() -> None:
|
||||
app = create_app(
|
||||
Settings(
|
||||
data_dir=tmp_path / "data",
|
||||
source_home=tmp_path / "home",
|
||||
api_token=SecretStr("synthetic"),
|
||||
automatic_memory_processing=False,
|
||||
live_sync_enabled=False,
|
||||
)
|
||||
)
|
||||
async with (
|
||||
app.router.lifespan_context(app),
|
||||
AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": "Bearer synthetic"},
|
||||
) as client,
|
||||
):
|
||||
for _ in range(500):
|
||||
if app.state.recovery in {"done", "failed"}:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
body = (await client.get("/api/v1/memory-processing")).json()
|
||||
assert body["write_drain"] == "running"
|
||||
assert body["write_drain_error"] is None
|
||||
|
||||
drain = app.state.memory_write_drain
|
||||
drain.liveness = "restarting"
|
||||
drain.last_error_code = "operational_error"
|
||||
body = (await client.get("/api/v1/memory-processing")).json()
|
||||
assert body["write_drain"] == "restarting"
|
||||
assert body["write_drain_error"] == "operational_error"
|
||||
drain.liveness = "running"
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -415,15 +415,15 @@ def test_shutdown_preserves_a_job_that_finished_after_the_running_snapshot(
|
||||
task = asyncio.create_task(waiting())
|
||||
supervisor._tasks[job.job_id] = task
|
||||
await asyncio.sleep(0)
|
||||
original = jobs.list_jobs
|
||||
original = jobs.find_jobs
|
||||
|
||||
def finish_after_snapshot(*args, **kwargs):
|
||||
found = original(*args, **kwargs)
|
||||
if kwargs.get("status") is ImportStatus.RUNNING:
|
||||
if tuple(kwargs.get("statuses") or ()) == (ImportStatus.RUNNING,):
|
||||
jobs.finish_owned(job.job_id, runner_token=token, target=terminal)
|
||||
return found
|
||||
|
||||
monkeypatch.setattr(jobs, "list_jobs", finish_after_snapshot)
|
||||
monkeypatch.setattr(jobs, "find_jobs", finish_after_snapshot)
|
||||
try:
|
||||
await supervisor.shutdown()
|
||||
assert jobs.require(job.job_id).status is terminal
|
||||
@@ -433,3 +433,45 @@ def test_shutdown_preserves_a_job_that_finished_after_the_running_snapshot(
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_abandon_makes_its_database_calls_off_the_event_loop(tmp_path, monkeypatch) -> None:
|
||||
"""Live sync abandons from its poll; a writer wait there froze the whole Core."""
|
||||
import threading
|
||||
|
||||
async def scenario() -> None:
|
||||
evidence, jobs = stores(tmp_path)
|
||||
supervisor = ImportSupervisor(adapters={}, evidence_store=evidence, job_store=jobs)
|
||||
job = jobs.create(user_id="local", source_id="fixture", operation="sync")
|
||||
job, _token = jobs.claim(job.job_id)
|
||||
loop_thread = threading.current_thread()
|
||||
calls: list[tuple[str, threading.Thread]] = []
|
||||
for name in ("require", "interrupt_running"):
|
||||
original = getattr(jobs, name)
|
||||
|
||||
def recorded(*args, _name=name, _original=original, **kwargs):
|
||||
calls.append((_name, threading.current_thread()))
|
||||
return _original(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(jobs, name, recorded)
|
||||
|
||||
async def wedged() -> None:
|
||||
# Ignores the first cancel, like a runner stuck in a thread.
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError:
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
task = asyncio.create_task(wedged())
|
||||
supervisor._tasks[job.job_id] = task
|
||||
await asyncio.sleep(0)
|
||||
try:
|
||||
assert await supervisor.abandon(job.job_id, timeout=0.01) is False
|
||||
finally:
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
assert {name for name, _thread in calls} == {"require", "interrupt_running"}
|
||||
assert all(thread is not loop_thread for _name, thread in calls)
|
||||
assert jobs.require(job.job_id).status is ImportStatus.INTERRUPTED
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -298,7 +298,7 @@ def test_hook_job_completes_after_file_identity_adoption(tmp_path) -> None:
|
||||
assert current is not None
|
||||
assert ingest_journal.matches_current(connection, current)
|
||||
connection.rollback()
|
||||
await before_hy_invocation()
|
||||
await before_hy_invocation(True)
|
||||
return SdkWriteReceipt()
|
||||
|
||||
drain = DurableWriteDrain(queue, execute=lambda _job: None)
|
||||
|
||||
@@ -641,3 +641,32 @@ def _job(*, created_at: datetime) -> ImportJob:
|
||||
created_at=created_at,
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
def test_attributing_changed_paths_happens_off_the_event_loop(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Resolving a host's batch of changed paths blocked the loop for seconds."""
|
||||
import threading
|
||||
|
||||
async def scenario() -> None:
|
||||
root, (_evidence, live, imports) = _source(tmp_path)
|
||||
watch = FileWatchLiveSync(
|
||||
roots={PI_DESCRIPTOR.source_id: root},
|
||||
supervisor=live,
|
||||
debounce_seconds=0.1,
|
||||
min_interval_seconds=0.1,
|
||||
)
|
||||
loop_thread = threading.current_thread()
|
||||
resolved_on: list[threading.Thread] = []
|
||||
original = watch.source_for
|
||||
|
||||
def recorded(path: Path) -> str | None:
|
||||
resolved_on.append(threading.current_thread())
|
||||
return original(path)
|
||||
|
||||
monkeypatch.setattr(watch, "source_for", recorded)
|
||||
|
||||
assert await watch.handle_changes({tmp_path / "elsewhere/session.jsonl"}) == ()
|
||||
assert resolved_on and loop_thread not in resolved_on
|
||||
await imports.shutdown()
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -318,9 +318,31 @@ def test_main_database_checkpoints_on_a_write_cadence(tmp_path, monkeypatch):
|
||||
with database.write() as db, db:
|
||||
db.execute("UPDATE counter SET value = 2 WHERE id = 1")
|
||||
|
||||
assert _access.wait_for_cadence_checkpoints()
|
||||
assert checkpoints and checkpoints[0][0] == "PASSIVE"
|
||||
|
||||
|
||||
def test_the_cadence_checkpoint_does_not_run_on_the_writing_thread(tmp_path, monkeypatch):
|
||||
"""The thread that wrote returns at once; the checkpointer takes the next turn."""
|
||||
path = _database(tmp_path)
|
||||
database = main_database(path)
|
||||
ran_on = []
|
||||
original = database._checkpoint_from_maintenance
|
||||
|
||||
def observed(mode):
|
||||
ran_on.append(threading.current_thread().name)
|
||||
return original(mode)
|
||||
|
||||
monkeypatch.setattr(database, "_checkpoint_from_maintenance", observed)
|
||||
monkeypatch.setattr(_access, "MAIN_DATABASE_CHECKPOINT_WRITE_INTERVAL", 1)
|
||||
|
||||
with database.write() as db, db:
|
||||
db.execute("UPDATE counter SET value = 1 WHERE id = 1")
|
||||
|
||||
assert _access.wait_for_cadence_checkpoints()
|
||||
assert ran_on == ["one-memory-checkpoint"]
|
||||
|
||||
|
||||
def test_main_database_warns_when_wal_stays_above_the_high_water_mark(
|
||||
tmp_path, monkeypatch, caplog
|
||||
):
|
||||
@@ -332,10 +354,85 @@ def test_main_database_warns_when_wal_stays_above_the_high_water_mark(
|
||||
|
||||
with database.write() as db, db:
|
||||
db.execute("UPDATE counter SET value = 1 WHERE id = 1")
|
||||
assert _access.wait_for_cadence_checkpoints()
|
||||
|
||||
assert any("main_database_wal_high" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
def test_a_wal_whose_frames_are_all_checkpointed_is_not_reported_high(
|
||||
tmp_path, monkeypatch, caplog
|
||||
):
|
||||
"""#245: the alert judges frames still waiting, not the file's size."""
|
||||
path = _database(tmp_path)
|
||||
database = main_database(path)
|
||||
monkeypatch.setattr(_access, "MAIN_DATABASE_CHECKPOINT_WRITE_INTERVAL", 1)
|
||||
# One page: any frame left uncopied would cross it, a fully copied WAL not.
|
||||
monkeypatch.setattr(_access, "MAIN_DATABASE_WAL_WARN_BYTES", 1)
|
||||
caplog.set_level("WARNING", logger="one_memory.database_access")
|
||||
|
||||
with database.write() as db, db:
|
||||
db.execute("UPDATE counter SET value = 1 WHERE id = 1")
|
||||
assert _access.wait_for_cadence_checkpoints()
|
||||
|
||||
assert database._wal_bytes() > 0
|
||||
assert not any("main_database_wal_high" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
def test_the_writer_caps_the_wal_it_leaves_behind(tmp_path):
|
||||
path = _database(tmp_path)
|
||||
with main_database(path).write() as db:
|
||||
limit = db.execute("PRAGMA journal_size_limit").fetchone()[0]
|
||||
assert limit == _access.MAIN_DATABASE_JOURNAL_SIZE_LIMIT_BYTES
|
||||
|
||||
|
||||
def test_an_idle_checkpoint_shrinks_the_wal_after_a_large_burst(tmp_path, monkeypatch):
|
||||
"""#245: a WAL that grew once used to stay that size for good."""
|
||||
path = _database(tmp_path)
|
||||
database = main_database(path)
|
||||
# No cadence checkpoint during the burst: the WAL has to grow.
|
||||
monkeypatch.setattr(_access, "MAIN_DATABASE_CHECKPOINT_WRITE_INTERVAL", 10_000_000)
|
||||
monkeypatch.setattr(_access, "MAIN_DATABASE_CHECKPOINT_SECONDS", 10_000_000.0)
|
||||
database._last_checkpoint_at = time.monotonic()
|
||||
with database.write() as db, db:
|
||||
db.execute("PRAGMA wal_autocheckpoint = 0")
|
||||
db.execute("CREATE TABLE blobs (body BLOB)")
|
||||
db.executemany(
|
||||
"INSERT INTO blobs VALUES (randomblob(?))", [(64 * 1024,) for _ in range(1_500)]
|
||||
)
|
||||
with database.write() as db:
|
||||
db.execute("PRAGMA wal_autocheckpoint = 1000")
|
||||
grown = database._wal_bytes()
|
||||
assert grown > _access.MAIN_DATABASE_JOURNAL_SIZE_LIMIT_BYTES
|
||||
|
||||
with database.read() as reader:
|
||||
reader.execute("SELECT COUNT(*) FROM blobs").fetchone()
|
||||
# Someone is reading: no truncate is even attempted.
|
||||
assert database.checkpoint_if_idle() is None
|
||||
assert _access.checkpoint_idle_main_databases() == 1
|
||||
|
||||
assert database._wal_bytes() <= _access.MAIN_DATABASE_JOURNAL_SIZE_LIMIT_BYTES
|
||||
with database.read() as reader:
|
||||
assert reader.execute("SELECT COUNT(*) FROM blobs").fetchone() == (1_500,)
|
||||
|
||||
|
||||
def test_a_busy_idle_checkpoint_is_skipped_not_raised(tmp_path):
|
||||
path = _database(tmp_path)
|
||||
database = main_database(path)
|
||||
with database.write() as db, db:
|
||||
db.execute("UPDATE counter SET value = 1 WHERE id = 1")
|
||||
# Another process's reader holds a snapshot: TRUNCATE cannot finish.
|
||||
outsider = sqlite3.connect(path)
|
||||
try:
|
||||
outsider.execute("BEGIN")
|
||||
outsider.execute("SELECT value FROM counter").fetchone()
|
||||
result = database.checkpoint_if_idle()
|
||||
assert result is None or result[0] == 1
|
||||
finally:
|
||||
outsider.close()
|
||||
assert database.checkpoint_if_idle() == (0, 0, 0)
|
||||
assert database._wal_bytes() == 0
|
||||
|
||||
|
||||
def test_main_database_maintenance_checkpoint_does_not_break_an_active_reader(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
|
||||
@@ -661,6 +661,8 @@ def test_memory_add_routes_do_not_collide_with_single_memory_routes(tmp_path):
|
||||
"task_id",
|
||||
"memory_ids",
|
||||
"error",
|
||||
"error_code",
|
||||
"next_retry_at",
|
||||
}
|
||||
status = await client.get(f"/api/v2/memories/additions/{request_id}")
|
||||
assert status.json()["evidence_id"] == created.json()["evidence_id"]
|
||||
|
||||
@@ -524,6 +524,11 @@ def test_features_api_get_put_reset_and_status(tmp_path, monkeypatch) -> None:
|
||||
"episode_recall": True,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
app.state.model_profile_store,
|
||||
"active_identity",
|
||||
lambda: ("active-profile", 1),
|
||||
)
|
||||
await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
|
||||
await app.state.memory_graph_backfill.wait()
|
||||
ultra = _by_name((await client.get("/api/v1/settings/memory-features")).json())
|
||||
|
||||
@@ -41,6 +41,8 @@ from one_memory.memory_evolution import (
|
||||
EvolveScheduler,
|
||||
EvolveService,
|
||||
EvolveStateStore,
|
||||
GraphBuildState,
|
||||
GraphBuildUser,
|
||||
MemoryGraphBackfill,
|
||||
evolve_due,
|
||||
)
|
||||
@@ -58,8 +60,10 @@ def test_mode_defaults_to_pro_and_the_env_still_forces_ultra(tmp_path, monkeypat
|
||||
monkeypatch.delenv("ONE_MEMORY_GRAPH_STORE", raising=False)
|
||||
store = ConfigStore(tmp_path)
|
||||
assert store.memory_mode() == "pro"
|
||||
assert store.memory_mode_explicitly_configured() is False
|
||||
assert effective_memory_mode(store.memory_mode()) == "pro"
|
||||
store.set_memory_mode("lite")
|
||||
assert store.memory_mode_explicitly_configured() is True
|
||||
assert ConfigStore(tmp_path).memory_mode() == "lite"
|
||||
assert json.loads((tmp_path / "config.json").read_text())["memory_mode"] == "lite"
|
||||
monkeypatch.setenv("ONE_MEMORY_GRAPH_STORE", "kuzu")
|
||||
@@ -70,6 +74,21 @@ def test_mode_defaults_to_pro_and_the_env_still_forces_ultra(tmp_path, monkeypat
|
||||
store.set_memory_mode("max") # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_old_config_defaults_to_implicit_pro_until_pro_is_explicitly_set(tmp_path) -> None:
|
||||
(tmp_path / "config.json").write_text(
|
||||
json.dumps({"schema_version": 1, "sources": {}}), encoding="utf-8"
|
||||
)
|
||||
store = ConfigStore(tmp_path)
|
||||
assert store.memory_mode() == "pro"
|
||||
assert store.memory_mode_explicitly_configured() is False
|
||||
|
||||
store.set_memory_mode("pro")
|
||||
|
||||
assert store.memory_mode_explicitly_configured() is True
|
||||
assert json.loads((tmp_path / "config.json").read_text())["memory_mode"] == "pro"
|
||||
assert ConfigStore(tmp_path).memory_mode_explicitly_configured() is True
|
||||
|
||||
|
||||
def test_an_untouched_install_writes_no_new_settings(tmp_path) -> None:
|
||||
store = ConfigStore(tmp_path)
|
||||
store.set_sync("cursor", True)
|
||||
@@ -158,6 +177,37 @@ def test_the_read_handle_rebuilds_on_a_mode_or_profile_change(monkeypatch, tmp_p
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_read_handle_can_bind_one_caller_mode_snapshot(monkeypatch, tmp_path) -> None:
|
||||
from one_memory import sdk_read_handle
|
||||
|
||||
callback_reads = 0
|
||||
|
||||
def current_mode() -> str:
|
||||
nonlocal callback_reads
|
||||
callback_reads += 1
|
||||
return "pro"
|
||||
|
||||
monkeypatch.setattr(
|
||||
sdk_read_handle,
|
||||
"SdkClientHandle",
|
||||
lambda profile, **kwargs: _FakeHandle(kwargs.get("mode") or "pro", profile.profile_id),
|
||||
)
|
||||
reads = sdk_read_handle.ActiveProfileReadHandle(
|
||||
profiles=_Profiles(identity=None), # type: ignore[arg-type]
|
||||
secrets=None, # type: ignore[arg-type]
|
||||
data_dir=tmp_path,
|
||||
memory_mode=current_mode,
|
||||
)
|
||||
|
||||
async def scenario():
|
||||
client = await reads.client_for_mode("lite")
|
||||
assert client.mode == "lite"
|
||||
await reads.close()
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert callback_reads == 0
|
||||
|
||||
|
||||
class _FakeHandle:
|
||||
def __init__(self, mode: str, profile_id: str = "p") -> None:
|
||||
self.mode = mode
|
||||
@@ -288,7 +338,7 @@ class _SdkHandle:
|
||||
return None
|
||||
|
||||
|
||||
async def _noop() -> None:
|
||||
async def _noop(_requires_generation: bool) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@@ -311,14 +361,14 @@ def test_a_note_in_lite_is_stored_as_raw_and_never_calls_add_extracted() -> None
|
||||
]
|
||||
|
||||
|
||||
def test_a_lite_write_runs_through_the_coordinator_without_a_model(monkeypatch) -> None:
|
||||
def test_a_lite_write_ignores_the_model_sedimentation_pause(monkeypatch) -> None:
|
||||
sdk = _WritingSdk()
|
||||
coordinator = sdk_write_processor.SdkWriteCoordinator(
|
||||
profiles=_Profiles(identity=None), # type: ignore[arg-type]
|
||||
secrets=None, # type: ignore[arg-type]
|
||||
data_dir=None,
|
||||
transport_factory=lambda _profile: None,
|
||||
sedimentation_enabled=lambda: True,
|
||||
sedimentation_enabled=lambda: False,
|
||||
memory_mode=lambda: "lite",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
@@ -335,6 +385,37 @@ def test_a_lite_write_runs_through_the_coordinator_without_a_model(monkeypatch)
|
||||
assert [name for name, _ in sdk.calls] == ["add"]
|
||||
|
||||
|
||||
def test_a_lite_write_ignores_the_model_sedimentation_switch(monkeypatch) -> None:
|
||||
sdk = _WritingSdk()
|
||||
coordinator = sdk_write_processor.SdkWriteCoordinator(
|
||||
profiles=_Profiles(identity=None), # type: ignore[arg-type]
|
||||
secrets=None, # type: ignore[arg-type]
|
||||
data_dir=None,
|
||||
transport_factory=lambda _profile: None,
|
||||
sedimentation_enabled=lambda: False,
|
||||
memory_mode=lambda: "lite",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sdk_write_processor, "SdkClientHandle", lambda profile, **kwargs: _SdkHandle(sdk, "lite")
|
||||
)
|
||||
admitted = 0
|
||||
|
||||
async def admit(_requires_generation: bool) -> None:
|
||||
nonlocal admitted
|
||||
admitted += 1
|
||||
|
||||
receipt = asyncio.run(
|
||||
coordinator.process_sdk_job(
|
||||
(_record("still stored as raw", extracted=False),),
|
||||
request_id="r-off-lite",
|
||||
before_hy_invocation=admit,
|
||||
)
|
||||
)
|
||||
assert receipt.raw_ids == ("raw-1",)
|
||||
assert admitted == 1
|
||||
assert [name for name, _ in sdk.calls] == ["add"]
|
||||
|
||||
|
||||
def test_no_raw_is_ever_deleted_in_any_mode() -> None:
|
||||
"""Owner decision: MemHub keeps every raw; there is no retention window."""
|
||||
from one_memory import sdk_memory_runtime
|
||||
@@ -624,6 +705,38 @@ def test_the_scheduler_runs_each_due_user_once_in_ultra_only(tmp_path, monkeypat
|
||||
assert state.user("wife").last_result["trigger"] == "scheduled"
|
||||
|
||||
|
||||
def test_scheduled_evolve_obeys_the_shared_lifecycle_gate(tmp_path, monkeypatch) -> None:
|
||||
sdk = _EvolveSdk(continuing=0)
|
||||
state = EvolveStateStore(tmp_path)
|
||||
state.anchor(_at(1, 12))
|
||||
open_gate = [False]
|
||||
service = EvolveService(
|
||||
coordinator=_Coordinator(sdk, monkeypatch),
|
||||
state=state,
|
||||
memory_mode=lambda: "ultra",
|
||||
now=lambda: _at(2, 3, 2),
|
||||
)
|
||||
|
||||
async def users():
|
||||
return ("local",)
|
||||
|
||||
async def scenario():
|
||||
scheduler = EvolveScheduler(
|
||||
service=service,
|
||||
schedule=EvolveSchedule,
|
||||
users_with_memories=users,
|
||||
lifecycle_lock=asyncio.Lock(),
|
||||
lifecycle_open=lambda: open_gate[0],
|
||||
now=lambda: _at(2, 3, 2),
|
||||
)
|
||||
assert await scheduler.tick() == ()
|
||||
assert sdk.calls == []
|
||||
open_gate[0] = True
|
||||
assert await scheduler.tick() == ("local",)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_the_graph_backfill_runs_once_per_user(tmp_path, monkeypatch) -> None:
|
||||
sdk = _EvolveSdk()
|
||||
state = EvolveStateStore(tmp_path)
|
||||
@@ -643,6 +756,63 @@ def test_the_graph_backfill_runs_once_per_user(tmp_path, monkeypatch) -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_an_interrupted_graph_backfill_resumes_only_unfinished_users(tmp_path, monkeypatch) -> None:
|
||||
sdk = _EvolveSdk()
|
||||
state = EvolveStateStore(tmp_path)
|
||||
state.set_graph_build(
|
||||
GraphBuildState(
|
||||
state="running",
|
||||
started_at=_at(1, 3),
|
||||
users={
|
||||
"local": GraphBuildUser(state="succeeded", memories_scanned=4, nodes=4),
|
||||
"wife": GraphBuildUser(state="running"),
|
||||
},
|
||||
)
|
||||
)
|
||||
backfill = MemoryGraphBackfill(coordinator=_Coordinator(sdk, monkeypatch), state=state)
|
||||
|
||||
async def scenario():
|
||||
resumed = backfill.recover(["local", "wife"])
|
||||
assert resumed.state == "running"
|
||||
assert resumed.users["local"].state == "succeeded"
|
||||
assert resumed.users["wife"].state == "pending"
|
||||
await backfill.wait()
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert sdk.graph_builds == ["wife"]
|
||||
assert state.graph_build().state == "succeeded"
|
||||
|
||||
|
||||
def test_graph_backfill_waits_for_embedding_maintenance_to_finish(tmp_path, monkeypatch) -> None:
|
||||
sdk = _EvolveSdk()
|
||||
state = EvolveStateStore(tmp_path)
|
||||
gate = [False]
|
||||
checked = asyncio.Event()
|
||||
|
||||
def lifecycle_open() -> bool:
|
||||
checked.set()
|
||||
return gate[0]
|
||||
|
||||
backfill = MemoryGraphBackfill(
|
||||
coordinator=_Coordinator(sdk, monkeypatch),
|
||||
state=state,
|
||||
lifecycle_open=lifecycle_open,
|
||||
)
|
||||
|
||||
async def scenario():
|
||||
backfill.start(["local"])
|
||||
await checked.wait()
|
||||
assert sdk.graph_builds == []
|
||||
assert state.graph_build().state == "running"
|
||||
assert state.graph_build().users["local"].state == "pending"
|
||||
gate[0] = True
|
||||
await backfill.wait()
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert sdk.graph_builds == ["local"]
|
||||
assert state.graph_build().state == "succeeded"
|
||||
|
||||
|
||||
# --- the HTTP surface -----------------------------------------------------------
|
||||
|
||||
|
||||
@@ -701,23 +871,38 @@ def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
current = (await client.get("/api/v1/settings/memory-mode")).json()
|
||||
assert current["mode"] == current["configured_mode"] == "pro"
|
||||
assert current["explicitly_configured"] is False
|
||||
assert current["forced_by_env"] is False
|
||||
assert app.state.sdk_write_coordinator.memory_mode() == "pro"
|
||||
assert (
|
||||
await client.put("/api/v1/sedimentation", json={"enabled": False})
|
||||
).status_code == 200
|
||||
from one_memory.api import _sedimentation_claim_enabled
|
||||
|
||||
app.state.history_import_gate_released = True
|
||||
assert not _sedimentation_claim_enabled(app, "one-memory-manual")
|
||||
|
||||
app.state.config_store.set_sedimentation_enabled(False)
|
||||
lite = await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"})
|
||||
assert lite.status_code == 200, lite.text
|
||||
body = lite.json()
|
||||
assert body["mode"] == "lite" and body["previous_mode"] == "pro"
|
||||
assert body["explicitly_configured"] is True
|
||||
assert body["effects"] == ["higher_mode_memories_hidden"]
|
||||
resume = getattr(app.state, "sedimentation_resume", None)
|
||||
assert resume is not None
|
||||
await asyncio.wait_for(asyncio.shield(resume), 5)
|
||||
assert app.state.sdk_write_coordinator.memory_mode() == "lite"
|
||||
assert _sdk_client_handle(app).memory_mode() == "lite"
|
||||
# lite claims queued work without a generation model.
|
||||
from one_memory.api import _sedimentation_claim_enabled
|
||||
|
||||
assert app.state.model_profile_store.active_identity() is None
|
||||
app.state.history_import_gate_released = True
|
||||
assert _sedimentation_claim_enabled(app, "one-memory-manual")
|
||||
|
||||
monkeypatch.setattr(
|
||||
app.state.model_profile_store,
|
||||
"active_identity",
|
||||
lambda: ("active-profile", 1),
|
||||
)
|
||||
ultra = await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
|
||||
assert ultra.status_code == 200, ultra.text
|
||||
assert "existing_memories_added_to_graph" in ultra.json()["effects"]
|
||||
@@ -741,6 +926,173 @@ def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None:
|
||||
run_app(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_mode_plan_and_put_share_validation_and_effects(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, _sdk, client):
|
||||
plan = await client.post(
|
||||
"/api/v1/settings/memory-mode/plan",
|
||||
json={"mode": "lite"},
|
||||
)
|
||||
assert plan.status_code == 200
|
||||
body = plan.json()
|
||||
assert body == {
|
||||
"current": "pro",
|
||||
"target": "lite",
|
||||
"effective_target": "lite",
|
||||
"capabilities": {
|
||||
"content_plane": "raw",
|
||||
"generation_required": False,
|
||||
"graph_enabled": False,
|
||||
"evolve_available": False,
|
||||
},
|
||||
"effects": ["higher_mode_memories_hidden"],
|
||||
"warnings": [],
|
||||
"blockers": [],
|
||||
}
|
||||
changed = await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"})
|
||||
assert changed.status_code == 200
|
||||
assert changed.json()["effects"] == body["effects"]
|
||||
|
||||
missing_model = (
|
||||
await client.post(
|
||||
"/api/v1/settings/memory-mode/plan",
|
||||
json={"mode": "pro"},
|
||||
)
|
||||
).json()
|
||||
assert missing_model["blockers"] == ["active_model_required"]
|
||||
assert missing_model["effects"] == []
|
||||
refused = await client.put("/api/v1/settings/memory-mode", json={"mode": "pro"})
|
||||
assert refused.status_code == 409
|
||||
assert refused.json()["detail"] == missing_model["blockers"][0]
|
||||
|
||||
monkeypatch.setenv("ONE_MEMORY_GRAPH_STORE", "kuzu")
|
||||
forced = (
|
||||
await client.post(
|
||||
"/api/v1/settings/memory-mode/plan",
|
||||
json={"mode": "lite"},
|
||||
)
|
||||
).json()
|
||||
assert forced["effective_target"] == "ultra"
|
||||
assert forced["warnings"] == ["forced_by_env", "effective_no_change"]
|
||||
|
||||
run_app(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_mode_plan_reports_all_blockers_without_planned_effects(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, _sdk, client):
|
||||
changed = await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"})
|
||||
assert changed.status_code == 200
|
||||
switch = app.state.embedding_switch
|
||||
switch._scan_hold = True
|
||||
hold = asyncio.Event()
|
||||
task = asyncio.create_task(hold.wait())
|
||||
app.state.memory_graph_backfill._task = task
|
||||
monkeypatch.setattr("one_memory.api.importlib.util.find_spec", lambda _name: None)
|
||||
try:
|
||||
plan = (
|
||||
await client.post(
|
||||
"/api/v1/settings/memory-mode/plan",
|
||||
json={"mode": "ultra"},
|
||||
)
|
||||
).json()
|
||||
assert plan["blockers"] == [
|
||||
"embedding_maintenance_in_progress",
|
||||
"active_model_required",
|
||||
"memory_lifecycle_busy",
|
||||
"graph_store_unavailable",
|
||||
]
|
||||
assert plan["effects"] == []
|
||||
finally:
|
||||
switch._scan_hold = False
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
app.state.memory_graph_backfill._task = None
|
||||
|
||||
run_app(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_lite_startup_readmits_jobs_while_model_sedimentation_is_off(tmp_path, monkeypatch) -> None:
|
||||
store = ConfigStore(tmp_path / "data")
|
||||
store.set_memory_mode("lite")
|
||||
store.set_sedimentation_enabled(False)
|
||||
resumed = asyncio.Event()
|
||||
original = SdkMemoryRuntime.resume_allowed
|
||||
|
||||
async def resume_allowed(runtime):
|
||||
await original(runtime)
|
||||
resumed.set()
|
||||
|
||||
monkeypatch.setattr(SdkMemoryRuntime, "resume_allowed", resume_allowed)
|
||||
|
||||
async def scenario(app, _sdk, _client):
|
||||
await asyncio.wait_for(resumed.wait(), 5)
|
||||
assert app.state.config_store.sedimentation_enabled() is False
|
||||
assert app.state.config_store.memory_mode() == "lite"
|
||||
|
||||
run_app(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_embedding_maintenance_blocks_mode_put_and_evolve_but_not_mode_get(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
async def scenario(app, _sdk, client):
|
||||
switch = app.state.embedding_switch
|
||||
|
||||
switch._scan_hold = True
|
||||
current = await client.get("/api/v1/settings/memory-mode")
|
||||
assert current.status_code == 200
|
||||
assert current.json()["explicitly_configured"] is False
|
||||
for response in (
|
||||
await client.put("/api/v1/settings/memory-mode", json={"mode": "pro"}),
|
||||
await client.post("/api/v2/evolve", json={}),
|
||||
):
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == "embedding_maintenance_in_progress"
|
||||
assert app.state.config_store.memory_mode_explicitly_configured() is False
|
||||
|
||||
switch._scan_hold = False
|
||||
switch._open_cache = True
|
||||
assert (await client.get("/api/v1/settings/memory-mode")).status_code == 200
|
||||
for response in (
|
||||
await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"}),
|
||||
await client.post("/api/v2/evolve", json={}),
|
||||
):
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == "embedding_maintenance_in_progress"
|
||||
|
||||
switch._open_cache = False
|
||||
explicitly_pro = await client.put("/api/v1/settings/memory-mode", json={"mode": "pro"})
|
||||
assert explicitly_pro.status_code == 200
|
||||
assert explicitly_pro.json()["explicitly_configured"] is True
|
||||
assert explicitly_pro.json()["effects"] == []
|
||||
|
||||
run_app(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_graph_backfill_blocks_mode_change_and_manual_evolve(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, _sdk, client):
|
||||
hold = asyncio.Event()
|
||||
task = asyncio.create_task(hold.wait())
|
||||
app.state.memory_graph_backfill._task = task
|
||||
try:
|
||||
for response in (
|
||||
await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"}),
|
||||
await client.post("/api/v2/evolve", json={}),
|
||||
):
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == "memory_lifecycle_busy"
|
||||
finally:
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
app.state.memory_graph_backfill._task = None
|
||||
|
||||
run_app(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_evolve_api_is_ultra_only_async_and_scoped_per_user(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
@@ -748,7 +1100,13 @@ def test_evolve_api_is_ultra_only_async_and_scoped_per_user(tmp_path, monkeypatc
|
||||
assert refused.status_code == 409
|
||||
assert refused.json()["detail"] == "memory_mode_not_ultra"
|
||||
|
||||
await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
|
||||
monkeypatch.setattr(
|
||||
app.state.model_profile_store,
|
||||
"active_identity",
|
||||
lambda: ("active-profile", 1),
|
||||
)
|
||||
changed = await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"})
|
||||
assert changed.status_code == 200, changed.text
|
||||
await app.state.memory_graph_backfill.wait()
|
||||
started = await client.post(
|
||||
"/api/v2/evolve", json={"user_id": "wife", "agent_id": "all-apps", "max_batches": 2}
|
||||
@@ -865,3 +1223,87 @@ def test_a_job_written_in_lite_stays_done_and_a_pending_job_takes_the_new_mode(
|
||||
await coordinator.close()
|
||||
|
||||
run_app(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_paused_model_still_writes_a_lite_job_through_the_durable_boundary(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
from one_memory.api import _admit_pipeline_invocation
|
||||
from one_memory.memory_server.durable_queue import DurableWriteDrain, execute_job
|
||||
|
||||
seen: list[str] = []
|
||||
|
||||
class Sdk:
|
||||
mode = "lite"
|
||||
|
||||
def add(self, messages, **kwargs):
|
||||
seen.append("add")
|
||||
return {"success": True, "raw_ids": ["raw-lite"]}
|
||||
|
||||
def add_extracted(self, contents, **kwargs):
|
||||
seen.append("add_extracted")
|
||||
raise AssertionError("lite must never call add_extracted")
|
||||
|
||||
monkeypatch.setattr(
|
||||
sdk_write_processor,
|
||||
"SdkClientHandle",
|
||||
lambda profile, **kwargs: _SdkHandle(Sdk(), "lite"),
|
||||
)
|
||||
coordinator = sdk_write_processor.SdkWriteCoordinator(
|
||||
profiles=_Profiles(identity=None), # type: ignore[arg-type]
|
||||
secrets=None, # type: ignore[arg-type]
|
||||
data_dir=None,
|
||||
transport_factory=lambda _profile: None,
|
||||
sedimentation_enabled=lambda: False,
|
||||
memory_mode=lambda: "lite",
|
||||
)
|
||||
|
||||
async def never(_job_id):
|
||||
return None
|
||||
|
||||
async def scenario(app, _sdk, client):
|
||||
app.state.memory_write_drain.stop()
|
||||
if app.state.memory_write_drain_task is not None:
|
||||
await app.state.memory_write_drain_task
|
||||
app.state.memory_write_drain_task = None
|
||||
await client.put("/api/v1/sedimentation", json={"enabled": False})
|
||||
await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"})
|
||||
app.state.history_import_gate_released = True
|
||||
written = await client.post(
|
||||
"/api/v2/memory-writes",
|
||||
json={
|
||||
"request_id": "paused-lite-turn",
|
||||
"session_id": "paused-lite-session",
|
||||
"source_id": "one-memory-manual",
|
||||
"messages": [
|
||||
{"role": "user", "content": "保留原始会话"},
|
||||
{"role": "assistant", "content": "已保留"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert written.status_code == 202
|
||||
queue = app.state.memory_write_queue
|
||||
drain = DurableWriteDrain(queue, execute=never, lease=timedelta(seconds=30))
|
||||
[job_id] = drain.claim(limit=1)
|
||||
await execute_job(
|
||||
job_id,
|
||||
queue=queue,
|
||||
supervisor=coordinator,
|
||||
evidence=None, # type: ignore[arg-type]
|
||||
admit_hy_invocation=lambda source_id, requires_generation, mark: (
|
||||
_admit_pipeline_invocation(
|
||||
app,
|
||||
source_id,
|
||||
requires_generation,
|
||||
mark,
|
||||
)
|
||||
),
|
||||
)
|
||||
current = queue.get("paused-lite-turn")
|
||||
assert current is not None and current.state == "completed"
|
||||
assert current.sdk_raw_ids == ("raw-lite",)
|
||||
assert seen == ["add"]
|
||||
await coordinator.close()
|
||||
|
||||
run_app(tmp_path, monkeypatch, scenario)
|
||||
@@ -832,7 +832,7 @@ class _Recorder:
|
||||
async def process_sdk_job(self, records, **_kwargs):
|
||||
self.seen.append(tuple(record.evidence_id for record in records))
|
||||
if self.invoke_hy:
|
||||
await _kwargs["before_hy_invocation"]()
|
||||
await _kwargs["before_hy_invocation"](True)
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return type("Receipt", (), {"raw_ids": (), "memory_ids": ()})()
|
||||
@@ -844,10 +844,10 @@ class _TwoInvocationRecorder:
|
||||
self.invocations = 0
|
||||
|
||||
async def process_sdk_job(self, _records, **kwargs):
|
||||
await kwargs["before_hy_invocation"]()
|
||||
await kwargs["before_hy_invocation"](True)
|
||||
self.invocations += 1
|
||||
self.pause_after_first()
|
||||
await kwargs["before_hy_invocation"]()
|
||||
await kwargs["before_hy_invocation"](True)
|
||||
self.invocations += 1
|
||||
return type("Receipt", (), {"raw_ids": (), "memory_ids": ()})()
|
||||
|
||||
@@ -916,7 +916,7 @@ def test_execute_job_persists_sdk_ids_returned_after_the_single_add(tmp_path) ->
|
||||
@classmethod
|
||||
async def process_sdk_job(cls, _records, **kwargs):
|
||||
cls.calls += 1
|
||||
await kwargs["before_hy_invocation"]()
|
||||
await kwargs["before_hy_invocation"](True)
|
||||
return type(
|
||||
"Result",
|
||||
(),
|
||||
@@ -1045,7 +1045,7 @@ def test_sdk_write_processor_calls_add_once_with_public_scope_and_receipt() -> N
|
||||
)
|
||||
|
||||
async def run() -> SdkWriteReceipt:
|
||||
async def before() -> None:
|
||||
async def before(_requires_generation: bool) -> None:
|
||||
client.events.append("before")
|
||||
|
||||
return await processor.process_sdk_job(
|
||||
@@ -1105,7 +1105,7 @@ def test_sdk_write_processor_uses_add_extracted_for_memory_content_and_additions
|
||||
processor = SdkWriteProcessor(_SdkPublicHandle(client)) # type: ignore[arg-type]
|
||||
|
||||
async def run() -> SdkWriteReceipt:
|
||||
async def before() -> None:
|
||||
async def before(_requires_generation: bool) -> None:
|
||||
client.events.append("before")
|
||||
|
||||
return await processor.process_sdk_job(
|
||||
@@ -1260,7 +1260,7 @@ def test_a_profile_change_does_not_close_the_client_of_a_write_in_flight(monkeyp
|
||||
|
||||
async def process(self, records, *, request_id, before_hy_invocation):
|
||||
client = await self._client_handle.client()
|
||||
await before_hy_invocation()
|
||||
await before_hy_invocation(getattr(client, "mode", None) != "lite")
|
||||
seen.append((client.profile_id, client.closed))
|
||||
if client.profile_id == "profile-a":
|
||||
await writing.wait()
|
||||
@@ -1270,7 +1270,7 @@ def test_a_profile_change_does_not_close_the_client_of_a_write_in_flight(monkeyp
|
||||
|
||||
monkeypatch.setattr(sdk_write_processor.SdkWriteProcessor, "process_sdk_job", process)
|
||||
|
||||
async def noop():
|
||||
async def noop(_requires_generation: bool):
|
||||
return None
|
||||
|
||||
async def scenario():
|
||||
@@ -1433,25 +1433,6 @@ def test_a_claimed_row_with_no_readable_payload_is_settled_not_left_running(tmp_
|
||||
assert _rows(path)[0]["lease_owner"] is None
|
||||
|
||||
|
||||
def test_an_unknown_outcome_is_recorded_as_unknown_not_retried(tmp_path) -> None:
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
record = queue.submit(_request())
|
||||
supervisor = _Recorder(error=RuntimeError("model_content_outcome_unknown"))
|
||||
_claim_through_drain(queue, record.job_id)
|
||||
|
||||
asyncio.run(
|
||||
execute_job(
|
||||
record.job_id,
|
||||
queue=queue,
|
||||
supervisor=supervisor,
|
||||
evidence=_EvidenceStore({"e1"}),
|
||||
)
|
||||
)
|
||||
|
||||
assert _state(path, record.job_id) == "unknown"
|
||||
|
||||
|
||||
def test_unpermitted_evidence_parks_and_is_readmitted_after_confirmation(tmp_path) -> None:
|
||||
"""A note claimed before its confirmation must not become terminal failed."""
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
@@ -1478,34 +1459,6 @@ def test_unpermitted_evidence_parks_and_is_readmitted_after_confirmation(tmp_pat
|
||||
assert replay.state == PENDING
|
||||
|
||||
|
||||
def test_a_permission_error_after_hy_invocation_stays_unknown(tmp_path) -> None:
|
||||
"""An error code cannot erase the durable possibly-sent boundary."""
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
record = queue.submit(_request())
|
||||
_claim_through_drain(queue, record.job_id)
|
||||
|
||||
asyncio.run(
|
||||
execute_job(
|
||||
record.job_id,
|
||||
queue=queue,
|
||||
supervisor=_Recorder(
|
||||
error=RuntimeError("model_content_evidence_changed"),
|
||||
invoke_hy=True,
|
||||
),
|
||||
evidence=_EvidenceStore({"e1"}),
|
||||
)
|
||||
)
|
||||
|
||||
assert queue.get(record.request_id).state == UNKNOWN
|
||||
with sqlite3.connect(path) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT hy_invoked,last_error FROM memory_write_jobs WHERE job_id=?",
|
||||
(record.job_id,),
|
||||
).fetchone() == (1, "model_content_evidence_changed")
|
||||
assert queue.reclaim_expired() == 0
|
||||
|
||||
|
||||
def test_replayed_live_evidence_readmits_a_pre_send_identity_failure(tmp_path) -> None:
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
@@ -1623,90 +1576,6 @@ def test_startup_never_repairs_a_possibly_sent_identity_failure(tmp_path) -> Non
|
||||
assert queue.get(job.request_id).state == UNKNOWN
|
||||
|
||||
|
||||
def test_reconciled_absent_timeout_is_requeued_once_without_resetting_attempts(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
record = queue.submit(_request())
|
||||
with sqlite3.connect(path) as connection, connection:
|
||||
connection.execute(
|
||||
"UPDATE memory_write_jobs SET state='unknown', attempts=1, "
|
||||
"hy_invoked=1, last_error='hy_memory_timeout', updated_at=? WHERE job_id=?",
|
||||
("2026-01-01T00:00:00+00:00", record.job_id),
|
||||
)
|
||||
|
||||
assert (
|
||||
queue.retry_timed_out_unknown_after_reconcile(
|
||||
older_than=datetime(2026, 1, 1, 0, 5, tzinfo=UTC)
|
||||
)
|
||||
== 1
|
||||
)
|
||||
with sqlite3.connect(path) as connection:
|
||||
state, attempts, invoked, error = connection.execute(
|
||||
"SELECT state, attempts, hy_invoked, last_error FROM memory_write_jobs WHERE job_id=?",
|
||||
(record.job_id,),
|
||||
).fetchone()
|
||||
assert (state, attempts, invoked, error) == (
|
||||
"pending",
|
||||
1,
|
||||
0,
|
||||
"model_content_reconciled_absent_retry",
|
||||
)
|
||||
|
||||
|
||||
def test_unresolved_unknown_is_automatically_retried_only_once(tmp_path) -> None:
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
record = queue.submit(_request())
|
||||
with sqlite3.connect(path) as connection, connection:
|
||||
connection.execute(
|
||||
"UPDATE memory_write_jobs SET state='unknown',attempts=1,hy_invoked=1,"
|
||||
"last_error='model_content_outcome_unknown',updated_at=? WHERE job_id=?",
|
||||
("2026-01-01T00:00:00+00:00", record.job_id),
|
||||
)
|
||||
|
||||
cutoff = datetime(2026, 1, 1, 0, 5, tzinfo=UTC)
|
||||
assert queue.retry_first_unknown_after_reconcile(older_than=cutoff) == 1
|
||||
first_retry = queue.get(record.request_id)
|
||||
assert first_retry.state == PENDING
|
||||
with sqlite3.connect(path) as connection:
|
||||
assert (
|
||||
connection.execute(
|
||||
"SELECT attempts FROM memory_write_jobs WHERE job_id=?",
|
||||
(record.job_id,),
|
||||
).fetchone()[0]
|
||||
== 1
|
||||
)
|
||||
|
||||
drain = DurableWriteDrain(queue, execute=_never_execute, owner="retry-owner")
|
||||
assert drain.claim(limit=1) == (record.job_id,)
|
||||
assert queue.mark_invoked(record.job_id, owner="retry-owner")
|
||||
queue.settle(
|
||||
record.job_id,
|
||||
state=UNKNOWN,
|
||||
owner="retry-owner",
|
||||
error_code="model_content_outcome_unknown",
|
||||
hy_invoked=True,
|
||||
)
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
assert (
|
||||
connection.execute(
|
||||
"SELECT attempts FROM memory_write_jobs WHERE job_id=?",
|
||||
(record.job_id,),
|
||||
).fetchone()[0]
|
||||
== 2
|
||||
)
|
||||
assert (
|
||||
queue.retry_first_unknown_after_reconcile(
|
||||
older_than=datetime.now(UTC) + timedelta(minutes=1)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert queue.get(record.request_id).state == UNKNOWN
|
||||
|
||||
|
||||
def test_drain_runs_unknown_reconciliation_during_busy_safe_maintenance(tmp_path) -> None:
|
||||
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||
calls = 0
|
||||
@@ -1759,26 +1628,6 @@ def test_a_reclaimed_uninvoked_job_with_attempts_left_is_retryable(tmp_path) ->
|
||||
assert _rows(path)[0]["attempts"] == 0
|
||||
|
||||
|
||||
def test_post_invocation_transport_failure_stays_unknown_not_pending(tmp_path) -> None:
|
||||
"""Once the bridge may have been opened, no error is safe to replay."""
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
record = queue.submit(_request())
|
||||
_claim_through_drain(queue, record.job_id)
|
||||
|
||||
asyncio.run(
|
||||
execute_job(
|
||||
record.job_id,
|
||||
queue=queue,
|
||||
supervisor=_Recorder(error=RuntimeError("hy_memory_timeout")),
|
||||
evidence=_EvidenceStore({"e1"}),
|
||||
)
|
||||
)
|
||||
|
||||
assert _state(path, record.job_id) == UNKNOWN
|
||||
assert _rows(path)[0]["hy_invoked"] == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code,expected",
|
||||
[
|
||||
@@ -1879,7 +1728,7 @@ def test_a_failed_job_logs_where_it_failed_but_never_the_message(tmp_path, caplo
|
||||
|
||||
class _Failing:
|
||||
async def process_sdk_job(self, _records, **kwargs):
|
||||
await kwargs["before_hy_invocation"]()
|
||||
await kwargs["before_hy_invocation"](True)
|
||||
try:
|
||||
closed_store(type("Client", (), {"private_store_name": None})())
|
||||
except AttributeError as error:
|
||||
@@ -1900,7 +1749,9 @@ def test_a_failed_job_logs_where_it_failed_but_never_the_message(tmp_path, caplo
|
||||
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 (
|
||||
f"job_id={record.job_id} error_code=RuntimeError state=pending 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
|
||||
@@ -2620,57 +2471,6 @@ def test_a_wedged_job_hits_the_work_ceiling_and_settles_unknown(tmp_path) -> Non
|
||||
assert (state, owner, expiry) == ("unknown", None, None)
|
||||
|
||||
|
||||
def test_user_confirmed_unknown_retry_requeues_job_and_linked_tasks(tmp_path) -> None:
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
record = queue.submit(_request(evidence=("e1",)))
|
||||
with sqlite3.connect(path) as connection, connection:
|
||||
connection.execute(
|
||||
"UPDATE memory_write_jobs SET state='unknown', attempts=2, hy_invoked=1, "
|
||||
"last_error='hy_memory_timeout' WHERE job_id=?",
|
||||
(record.job_id,),
|
||||
)
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE memory_tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
runner_token TEXT,
|
||||
attempts INTEGER NOT NULL,
|
||||
last_error_code TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE memory_task_inputs (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
refs_json TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO memory_tasks VALUES (?,?,?,?,?,?)",
|
||||
("task-1", "unknown", None, 2, "model_content_outcome_unknown", "old"),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO memory_task_inputs VALUES (?,?)",
|
||||
("task-1", '[{"evidence_id":"e1"}]'),
|
||||
)
|
||||
|
||||
assert queue.retry_unknown_confirmed(record.job_id) == 1
|
||||
retried = queue.get(record.request_id)
|
||||
assert retried is not None
|
||||
assert retried.state == PENDING
|
||||
with sqlite3.connect(path) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT attempts, hy_invoked FROM memory_write_jobs WHERE job_id=?",
|
||||
(record.job_id,),
|
||||
).fetchone() == (0, 0)
|
||||
assert connection.execute(
|
||||
"SELECT status, attempts, last_error_code FROM memory_tasks WHERE task_id='task-1'"
|
||||
).fetchone() == ("pending", 0, "model_content_manual_retry_requested")
|
||||
|
||||
assert queue.retry_unknown_confirmed(record.job_id) == 0
|
||||
|
||||
|
||||
def test_restart_redrains_a_job_claimed_by_a_worker_that_died(tmp_path) -> None:
|
||||
"""End-to-end restart recovery: a claimed-but-not-run job runs once.
|
||||
|
||||
@@ -2922,30 +2722,6 @@ def test_forever_drain_admits_a_new_source_while_an_earlier_source_is_running(tm
|
||||
assert queue.get("workbuddy-1").state == COMPLETED
|
||||
|
||||
|
||||
def test_sqlite_drain_preserves_unknown_after_execute_job(tmp_path) -> None:
|
||||
"""A drain failure after invocation must remain unknown, not look retryable."""
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
queue.submit(_request(evidence=("e1",)))
|
||||
|
||||
async def execute(job_id: str) -> None:
|
||||
await execute_job(
|
||||
job_id,
|
||||
queue=queue,
|
||||
supervisor=_Recorder(error=RuntimeError("model_content_outcome_unknown")),
|
||||
evidence=_EvidenceStore({"e1"}),
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
drain = DurableWriteDrain(queue, execute=execute)
|
||||
assert await drain.run_one() == 1
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert queue.get("idem-1").state == "unknown"
|
||||
assert queue.reclaim_expired() == 0
|
||||
assert queue.get("idem-1").state == "unknown"
|
||||
|
||||
|
||||
def test_sqlite_drain_keeps_running_after_an_executor_raises(tmp_path) -> None:
|
||||
"""One executor exception must not kill the drain or strand other rows.
|
||||
|
||||
@@ -4222,27 +3998,6 @@ def test_set_state_rejects_an_unvalidated_state(tmp_path) -> None:
|
||||
assert registry.get(record.request_id).state == "accepted"
|
||||
|
||||
|
||||
def test_set_state_cannot_resolve_unknown_without_an_inquiry(tmp_path) -> None:
|
||||
"""The HTTP-facing setter must not bypass the inquiry-only exit.
|
||||
|
||||
`DurableWriteQueue.settle` deliberately treats `unknown` as sticky; a
|
||||
direct `set_state("completed"/"failed")` would otherwise turn a
|
||||
possibly-delivered write into a terminal claim without asking HY. The
|
||||
durable registry is the shared caller surface, so it must preserve that
|
||||
boundary even though today only tests call it.
|
||||
"""
|
||||
from one_memory.memory_server.durable_queue import DurableWriteRegistry
|
||||
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
registry = DurableWriteRegistry(path)
|
||||
durable = registry.queue.submit(_request())
|
||||
registry.queue.settle(durable.job_id, state=UNKNOWN, hy_invoked=True)
|
||||
|
||||
assert registry.set_state(durable.request_id, "completed") is None
|
||||
assert registry.set_state(durable.request_id, "failed") is None
|
||||
assert registry.get(durable.request_id).state == UNKNOWN
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", (WAITING_FOR_PERMISSION, WAITING_FOR_MODEL))
|
||||
def test_set_state_accepts_the_route_parked_states(tmp_path, state: str) -> None:
|
||||
"""The durable setter must accept every non-terminal route state.
|
||||
@@ -4383,6 +4138,71 @@ def test_a_failing_renewal_does_not_kill_the_drain(tmp_path) -> None:
|
||||
assert queue.get("idem-1").state == COMPLETED
|
||||
|
||||
|
||||
def test_a_locked_fallback_settlement_does_not_kill_the_drain(tmp_path, monkeypatch) -> None:
|
||||
"""The incident shape: executor and its fallback both lose the writer.
|
||||
|
||||
The first row keeps its lease for normal reclaim; an unrelated row must
|
||||
still run instead of being stranded behind a dead drain task.
|
||||
"""
|
||||
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||
first = queue.submit(_request(key="first", source="cursor"))
|
||||
second = queue.submit(_request(key="second", source="codex"))
|
||||
executed: list[str] = []
|
||||
|
||||
async def execute(job_id: str) -> None:
|
||||
executed.append(job_id)
|
||||
if job_id == first.job_id:
|
||||
raise RuntimeError("executor failed")
|
||||
queue.settle(job_id, state=COMPLETED)
|
||||
drain.stop()
|
||||
|
||||
drain = DurableWriteDrain(queue, execute=execute, max_concurrent_sources=1)
|
||||
|
||||
def locked_settlement(*_args, **_kwargs):
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
|
||||
monkeypatch.setattr(queue, "settle_worker_error", locked_settlement)
|
||||
|
||||
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
|
||||
|
||||
assert executed == [first.job_id, second.job_id]
|
||||
assert queue.get("first").state == RUNNING
|
||||
assert queue.get("second").state == COMPLETED
|
||||
|
||||
|
||||
def test_an_unexpected_job_task_failure_is_contained_by_the_drain(
|
||||
tmp_path, monkeypatch, caplog
|
||||
) -> None:
|
||||
"""A programming error stays visible without taking down the consumer."""
|
||||
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||
first = queue.submit(_request(key="first", source="cursor"))
|
||||
second = queue.submit(_request(key="second", source="codex"))
|
||||
executed: list[str] = []
|
||||
|
||||
async def run_job(job_id: str) -> None:
|
||||
executed.append(job_id)
|
||||
if job_id == first.job_id:
|
||||
raise RuntimeError("unexpected job task failure")
|
||||
drain.stop()
|
||||
|
||||
drain = DurableWriteDrain(
|
||||
queue,
|
||||
execute=_never_execute,
|
||||
max_concurrent_sources=1,
|
||||
)
|
||||
monkeypatch.setattr(drain, "_run_claimed_job", run_job)
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="one_memory.memory_server.durable_queue"):
|
||||
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
|
||||
|
||||
assert executed == [first.job_id, second.job_id]
|
||||
assert any(
|
||||
"durable_write_drain_job_task_failed" in message.getMessage()
|
||||
and "error_type=RuntimeError" in message.getMessage()
|
||||
for message in caplog.records
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("terminal", [COMPLETED, FAILED, DEAD])
|
||||
def test_a_terminal_row_is_sticky_against_a_late_settle(tmp_path, terminal: str) -> None:
|
||||
"""A late settle must not overwrite an outcome that already happened.
|
||||
@@ -5003,7 +4823,7 @@ def test_sedimentation_pause_releases_claim_without_spending_an_attempt(tmp_path
|
||||
drain = DurableWriteDrain(queue, execute=_never_execute, owner="pause-owner")
|
||||
assert drain.claim(limit=1) == (record.job_id,)
|
||||
|
||||
async def refuse_before_hy(_source_id, _mark_invoked):
|
||||
async def refuse_before_hy(_source_id, _requires_generation, _mark_invoked):
|
||||
raise RuntimeError("model_sedimentation_disabled")
|
||||
|
||||
asyncio.run(
|
||||
@@ -5225,6 +5045,27 @@ def test_permanent_renewal_error_stops_the_worker(tmp_path, monkeypatch) -> None
|
||||
asyncio.run(drain.run_one())
|
||||
|
||||
|
||||
def test_permanent_renewal_error_stops_the_long_lived_drain(tmp_path, monkeypatch) -> None:
|
||||
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||
queue.submit(_request())
|
||||
drain = DurableWriteDrain(
|
||||
queue,
|
||||
execute=lambda _job_id: asyncio.sleep(0.03),
|
||||
lease=timedelta(seconds=1),
|
||||
renew_every=timedelta(milliseconds=10),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
drain,
|
||||
"renew",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
sqlite3.OperationalError("database schema is corrupt")
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
|
||||
|
||||
|
||||
def test_lost_renewal_waits_for_execution_before_releasing_source(tmp_path, monkeypatch) -> None:
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
@@ -5435,83 +5276,6 @@ def test_readmit_paths_never_requeue_a_possibly_sent_row(tmp_path) -> None:
|
||||
assert DurableWriteDrain(queue, execute=_never_execute).claim(limit=5) == ()
|
||||
|
||||
|
||||
def test_a_refused_source_context_is_nonblocking_unknown(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""An ambiguous call-boundary failure is retained but never blocks FIFO."""
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
record = queue.submit(_request(key="refused-scope", source="opencode"))
|
||||
claimer = DurableWriteDrain(queue, execute=_never_execute, owner="refused-owner")
|
||||
assert claimer.claim(limit=1) == (record.job_id,)
|
||||
|
||||
asyncio.run(
|
||||
execute_job(
|
||||
record.job_id,
|
||||
queue=queue,
|
||||
supervisor=_Recorder(
|
||||
error=RuntimeError("hy_memory_write_verification_context_scope"),
|
||||
invoke_hy=True,
|
||||
),
|
||||
evidence=_EvidenceStore({"e1"}),
|
||||
owner="refused-owner",
|
||||
)
|
||||
)
|
||||
|
||||
with sqlite3.connect(path.as_uri() + "?mode=ro", uri=True) as db:
|
||||
state, invoked, last_error = db.execute(
|
||||
"SELECT state, hy_invoked, last_error FROM memory_write_jobs WHERE job_id=?",
|
||||
(record.job_id,),
|
||||
).fetchone()
|
||||
|
||||
assert state == UNKNOWN
|
||||
assert invoked == 1
|
||||
assert last_error == "hy_memory_write_verification_context_scope"
|
||||
assert DurableWriteDrain(queue, execute=_never_execute).claim(limit=1) == ()
|
||||
|
||||
|
||||
def test_a_failure_after_the_call_still_parks_as_unknown(tmp_path) -> None:
|
||||
"""The fix must not widen: a post-call refusal is still possibly-sent."""
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
record = queue.submit(_request(key="after-call", source="opencode"))
|
||||
claimer = DurableWriteDrain(queue, execute=_never_execute, owner="after-call-owner")
|
||||
assert claimer.claim(limit=1) == (record.job_id,)
|
||||
recorder = _Recorder(
|
||||
error=RuntimeError("hy_memory_write_verification_authority_invalid"),
|
||||
invoke_hy=True,
|
||||
)
|
||||
|
||||
asyncio.run(
|
||||
execute_job(
|
||||
record.job_id,
|
||||
queue=queue,
|
||||
supervisor=recorder,
|
||||
evidence=_EvidenceStore({"e1"}),
|
||||
owner="after-call-owner",
|
||||
)
|
||||
)
|
||||
|
||||
with sqlite3.connect(path.as_uri() + "?mode=ro", uri=True) as db:
|
||||
state, invoked = db.execute(
|
||||
"SELECT state, hy_invoked FROM memory_write_jobs WHERE job_id=?",
|
||||
(record.job_id,),
|
||||
).fetchone()
|
||||
|
||||
assert state == UNKNOWN
|
||||
assert invoked == 1
|
||||
assert len(recorder.seen) == 1
|
||||
assert (
|
||||
queue.retry_first_unknown_after_reconcile(
|
||||
older_than=datetime.now(UTC) + timedelta(minutes=1)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert queue.retry_unknown_confirmed(record.job_id) == 0
|
||||
assert DurableWriteDrain(queue, execute=_never_execute).claim(limit=1) == ()
|
||||
assert len(recorder.seen) == 1
|
||||
|
||||
|
||||
def test_event_time_not_enqueue_order_drives_the_window(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
@@ -5674,3 +5438,280 @@ def test_hand_added_memories_still_run_one_at_a_time_in_order(tmp_path) -> None:
|
||||
assert drain.claim(limit=5) == ()
|
||||
assert queue.get("note-2").state == PENDING
|
||||
assert second.job_id
|
||||
|
||||
|
||||
def test_a_job_the_execute_gate_releases_is_not_reclaimed_while_it_still_refuses(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""The claim gate and the execute gate can disagree for good (#245).
|
||||
|
||||
The claim asks about the lane's user, the execute gate about the user of
|
||||
the job's Evidence. An id-only pre-upgrade payload sits in the `local`
|
||||
lane even when its Evidence belongs to someone else: while that user
|
||||
evolves, the claim admits the job and the execute gate hands it back.
|
||||
Released with no hold, the very next tick claimed it again, in a loop.
|
||||
"""
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
record = queue.submit(_request(key="evolving-user", source="cursor"))
|
||||
drain = DurableWriteDrain(queue, execute=_never_execute, owner="gate-owner")
|
||||
assert drain.claim(limit=1) == (record.job_id,)
|
||||
|
||||
asyncio.run(
|
||||
execute_job(
|
||||
record.job_id,
|
||||
queue=queue,
|
||||
supervisor=_Recorder(error=RuntimeError("memory_evolve_in_progress")),
|
||||
evidence=_EvidenceStore({"e1"}),
|
||||
owner="gate-owner",
|
||||
)
|
||||
)
|
||||
|
||||
assert queue.get(record.request_id).state == PENDING
|
||||
now = datetime.now(UTC)
|
||||
assert drain.claim(limit=1, now=now) == ()
|
||||
assert drain.claim(limit=1, now=now + timedelta(seconds=4)) == ()
|
||||
assert drain.claim(limit=1, now=now + timedelta(seconds=6)) == (record.job_id,)
|
||||
with sqlite3.connect(path) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT attempts,hy_invoked FROM memory_write_jobs WHERE job_id=?",
|
||||
(record.job_id,),
|
||||
).fetchone() == (1, 0)
|
||||
|
||||
|
||||
def _suspension_runtime(queue: DurableWriteQueue, enabled: set[str]):
|
||||
from one_memory.sdk_memory_runtime import SdkMemoryRuntime
|
||||
|
||||
return SdkMemoryRuntime(
|
||||
coordinator=SimpleNamespace(),
|
||||
read_handle=SimpleNamespace(), # type: ignore[arg-type]
|
||||
write_queue=queue,
|
||||
extraction_settings=SimpleNamespace(), # type: ignore[arg-type]
|
||||
data_dir=queue.database_path.parent,
|
||||
source_active=lambda source_key: source_key in enabled,
|
||||
)
|
||||
|
||||
|
||||
def test_a_disabled_source_suspends_its_jobs_and_enabling_it_resumes_them(tmp_path) -> None:
|
||||
"""A source the user turned off keeps its jobs, out of the pending count (#245)."""
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
recent = datetime.now(UTC)
|
||||
for index in range(3):
|
||||
queue.submit(_request(key=f"cursor-{index}", source="cursor", event_at=recent))
|
||||
codex = queue.submit(_request(key="codex-0", source="codex", event_at=recent))
|
||||
enabled = {"cursor", "codex"}
|
||||
runtime = _suspension_runtime(queue, enabled)
|
||||
drain = DurableWriteDrain(
|
||||
queue,
|
||||
execute=_never_execute,
|
||||
owner="suspend-owner",
|
||||
source_writes_permitted=lambda source_key: source_key in enabled,
|
||||
)
|
||||
|
||||
assert runtime.status_counts() == {PENDING: 4}
|
||||
assert runtime.queue_depths().fresh == 4
|
||||
|
||||
enabled.discard("cursor")
|
||||
|
||||
assert runtime.status_counts() == {PENDING: 1, "suspended": 3}
|
||||
assert runtime.queue_depths().fresh == 1
|
||||
assert drain.claim(limit=10) == (codex.job_id,)
|
||||
queue.settle(codex.job_id, state=COMPLETED, hy_invoked=True, owner="suspend-owner")
|
||||
assert drain.claim(limit=10) == ()
|
||||
assert runtime.status_counts() == {COMPLETED: 1, "suspended": 3}
|
||||
# Nothing was cancelled, moved or deleted: the rows are as they were.
|
||||
assert [row["state"] for row in _rows(path) if row["source_key"] == "cursor"] == [PENDING] * 3
|
||||
|
||||
enabled.add("cursor")
|
||||
|
||||
assert runtime.status_counts() == {PENDING: 3, COMPLETED: 1}
|
||||
assert runtime.queue_depths().fresh == 3
|
||||
assert len(drain.claim(limit=10)) == 1
|
||||
|
||||
|
||||
def test_a_claim_path_error_restarts_the_drain_and_later_jobs_still_run(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""An unexpected SQLite error on the claim path used to end the drain for good."""
|
||||
from one_memory.memory_server import durable_queue as _queue_module
|
||||
|
||||
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||
queue.submit(_request(key="later", source="cursor"))
|
||||
reported: list[tuple[str, BaseException]] = []
|
||||
monkeypatch.setattr(
|
||||
_queue_module,
|
||||
"report_exception",
|
||||
lambda component, event, error, **_kwargs: reported.append((event, error)),
|
||||
)
|
||||
|
||||
async def execute(job_id: str) -> None:
|
||||
queue.settle(job_id, state=COMPLETED)
|
||||
drain.stop()
|
||||
|
||||
drain = DurableWriteDrain(queue, execute=execute, max_concurrent_sources=1)
|
||||
real_claim = drain.claim
|
||||
calls = {"claim": 0}
|
||||
|
||||
def failing_once(**kwargs):
|
||||
calls["claim"] += 1
|
||||
if calls["claim"] == 1:
|
||||
raise sqlite3.OperationalError("disk I/O error")
|
||||
return real_claim(**kwargs)
|
||||
|
||||
monkeypatch.setattr(drain, "claim", failing_once)
|
||||
first_owner = drain.owner
|
||||
|
||||
asyncio.run(
|
||||
asyncio.wait_for(
|
||||
drain.run_supervised(idle_pause=0.005, restart_backoff=0.01, max_restart_backoff=0.05),
|
||||
2,
|
||||
)
|
||||
)
|
||||
|
||||
assert queue.get("later").state == COMPLETED
|
||||
assert drain.restarts == 1
|
||||
assert drain.last_error_code == "operational_error"
|
||||
assert drain.liveness == "stopped"
|
||||
# A restarted drain leases under a new name: nothing the old one left in a
|
||||
# pool thread can renew or settle as it.
|
||||
assert drain.owner != first_owner
|
||||
assert [event for event, _error in reported] == ["memory.write_queue.drain_exited"]
|
||||
assert reported[0][1].__traceback__ is not None
|
||||
|
||||
|
||||
def test_a_claim_gate_exception_restarts_the_drain(tmp_path, monkeypatch) -> None:
|
||||
from one_memory.memory_server import durable_queue as _queue_module
|
||||
|
||||
monkeypatch.setattr(_queue_module, "report_exception", lambda *_args, **_kwargs: None)
|
||||
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||
queue.submit(_request(key="gated", source="cursor"))
|
||||
asked = {"count": 0}
|
||||
|
||||
def gate(_source_key: str) -> bool:
|
||||
asked["count"] += 1
|
||||
if asked["count"] == 1:
|
||||
raise KeyError("gate bug")
|
||||
return True
|
||||
|
||||
async def execute(job_id: str) -> None:
|
||||
queue.settle(job_id, state=COMPLETED)
|
||||
drain.stop()
|
||||
|
||||
drain = DurableWriteDrain(queue, execute=execute, source_claim_enabled=gate)
|
||||
|
||||
asyncio.run(asyncio.wait_for(drain.run_supervised(idle_pause=0.005, restart_backoff=0.01), 2))
|
||||
|
||||
assert queue.get("gated").state == COMPLETED
|
||||
assert drain.last_error_code == "key_error"
|
||||
|
||||
|
||||
def test_stop_ends_a_restart_backoff_at_once(tmp_path, monkeypatch) -> None:
|
||||
from one_memory.memory_server import durable_queue as _queue_module
|
||||
|
||||
monkeypatch.setattr(_queue_module, "report_exception", lambda *_args, **_kwargs: None)
|
||||
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||
drain = DurableWriteDrain(queue, execute=_never_execute)
|
||||
|
||||
def broken(**_kwargs):
|
||||
raise sqlite3.DatabaseError("database disk image is malformed")
|
||||
|
||||
monkeypatch.setattr(drain, "claim", broken)
|
||||
|
||||
async def scenario() -> None:
|
||||
task = asyncio.create_task(drain.run_supervised(restart_backoff=30))
|
||||
for _ in range(200):
|
||||
if drain.liveness == "restarting":
|
||||
break
|
||||
await asyncio.sleep(0.005)
|
||||
assert drain.liveness == "restarting"
|
||||
drain.stop()
|
||||
await asyncio.wait_for(task, 1)
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert drain.liveness == "stopped"
|
||||
assert drain.last_error_code == "database_error"
|
||||
|
||||
|
||||
def test_a_cancelled_supervised_drain_stays_cancelled(tmp_path) -> None:
|
||||
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||
drain = DurableWriteDrain(queue, execute=_never_execute)
|
||||
|
||||
async def scenario() -> None:
|
||||
task = asyncio.create_task(drain.run_supervised(idle_pause=0.005))
|
||||
await asyncio.sleep(0.05)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert drain.liveness == "stopped"
|
||||
assert drain.restarts == 0
|
||||
|
||||
|
||||
def test_a_contained_job_task_failure_is_reported_with_its_stack(tmp_path, monkeypatch) -> None:
|
||||
from one_memory.memory_server import durable_queue as _queue_module
|
||||
|
||||
reported: list[tuple[str, BaseException]] = []
|
||||
monkeypatch.setattr(
|
||||
_queue_module,
|
||||
"report_exception",
|
||||
lambda component, event, error, **_kwargs: reported.append((event, error)),
|
||||
)
|
||||
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||
first = queue.submit(_request(key="first", source="cursor"))
|
||||
queue.submit(_request(key="second", source="codex"))
|
||||
|
||||
async def run_job(job_id: str) -> None:
|
||||
if job_id == first.job_id:
|
||||
raise RuntimeError("systematic job task bug")
|
||||
drain.stop()
|
||||
|
||||
drain = DurableWriteDrain(queue, execute=_never_execute, max_concurrent_sources=1)
|
||||
monkeypatch.setattr(drain, "_run_claimed_job", run_job)
|
||||
|
||||
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
|
||||
|
||||
assert [event for event, _error in reported] == ["memory.write_queue.job_task_failed"]
|
||||
assert reported[0][1].__traceback__ is not None
|
||||
|
||||
|
||||
def test_a_lease_is_renewed_while_every_bulk_thread_is_blocked(tmp_path) -> None:
|
||||
"""The incident: bulk threads stuck in job listings starved lease renewal."""
|
||||
from one_memory.background_work import BULK_WORKERS, bulk_pool, shutdown_bulk_pool
|
||||
|
||||
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||
queue.submit(_request(key="slow", source="cursor"))
|
||||
release = threading.Event()
|
||||
blocked = [bulk_pool().submit(release.wait, 10) for _ in range(BULK_WORKERS * 2)]
|
||||
renewed: list[bool] = []
|
||||
|
||||
async def execute(job_id: str) -> None:
|
||||
# Longer than the lease: only renewals keep it.
|
||||
await asyncio.sleep(0.8)
|
||||
queue.settle(job_id, state=COMPLETED, owner=drain.owner)
|
||||
|
||||
drain = DurableWriteDrain(
|
||||
queue,
|
||||
execute=execute,
|
||||
lease=timedelta(milliseconds=400),
|
||||
renew_every=timedelta(milliseconds=50),
|
||||
)
|
||||
real_renew = drain.renew
|
||||
|
||||
def counted(job_id: str, **kwargs) -> bool:
|
||||
result = real_renew(job_id, **kwargs)
|
||||
renewed.append(result)
|
||||
return result
|
||||
|
||||
drain.renew = counted # type: ignore[method-assign]
|
||||
try:
|
||||
assert asyncio.run(asyncio.wait_for(drain.run_one(), 3)) == 1
|
||||
finally:
|
||||
release.set()
|
||||
for future in blocked:
|
||||
future.result(timeout=5)
|
||||
shutdown_bulk_pool()
|
||||
|
||||
assert renewed and all(renewed)
|
||||
assert queue.get("slow").state == COMPLETED
|
||||
@@ -17,6 +17,7 @@ from one_memory.memory_server.migration_runner import (
|
||||
InMemoryMigrationStore,
|
||||
MigrationResult,
|
||||
MigrationSchemaMismatchError,
|
||||
SqliteMigrationStore,
|
||||
ledger_sql,
|
||||
migrate,
|
||||
)
|
||||
@@ -138,6 +139,76 @@ def test_sdk_write_receipts_are_owned_by_migration_56() -> None:
|
||||
assert "CREATE TABLE IF NOT EXISTS memory_task_sdk_receipts" in ddl
|
||||
|
||||
|
||||
def test_onboarding_progress_migrates_old_sqlite_columns_in_append_order(tmp_path) -> None:
|
||||
path = tmp_path / "old-onboarding.sqlite3"
|
||||
with sqlite3.connect(path) as database:
|
||||
database.execute(
|
||||
"""
|
||||
CREATE TABLE onboarding_state (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
status TEXT NOT NULL,
|
||||
selected_source_ids_json TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
database.execute(
|
||||
"INSERT INTO onboarding_state VALUES "
|
||||
"(1,'importing','[\"cursor\"]',NULL,NULL,'2026-11-04T00:00:00+00:00')"
|
||||
)
|
||||
|
||||
migration = next(item for item in MIGRATIONS if item.version == 62)
|
||||
result = asyncio.run(migrate(SqliteMigrationStore(path), (migration,)))
|
||||
|
||||
assert result.applied == (62,)
|
||||
with sqlite3.connect(path) as database:
|
||||
columns = [str(row[1]) for row in database.execute("PRAGMA table_info(onboarding_state)")]
|
||||
row = database.execute(
|
||||
"SELECT mode_intent, setup_step, model_profile_id FROM onboarding_state"
|
||||
).fetchone()
|
||||
assert columns == [
|
||||
"singleton",
|
||||
"status",
|
||||
"selected_source_ids_json",
|
||||
"started_at",
|
||||
"completed_at",
|
||||
"updated_at",
|
||||
"mode_intent",
|
||||
"setup_step",
|
||||
"model_profile_id",
|
||||
]
|
||||
assert row == (None, None, None)
|
||||
|
||||
|
||||
def test_onboarding_progress_mysql_retries_each_duplicate_column() -> None:
|
||||
class DuplicateColumnError(Exception):
|
||||
pass
|
||||
|
||||
class Cursor:
|
||||
dialect = "mysql"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.attempted: list[str] = []
|
||||
self.applied: list[str] = []
|
||||
|
||||
async def execute(self, statement: str) -> None:
|
||||
self.attempted.append(statement)
|
||||
if "ADD COLUMN mode_intent" in statement or "ADD COLUMN setup_step" in statement:
|
||||
raise DuplicateColumnError(1060, "duplicate column")
|
||||
self.applied.append(statement)
|
||||
|
||||
cursor = Cursor()
|
||||
migration = next(item for item in MIGRATIONS if item.version == 62)
|
||||
|
||||
asyncio.run(migration.apply(cursor))
|
||||
|
||||
assert len(cursor.attempted) == 3
|
||||
assert len(cursor.applied) == 1
|
||||
assert "model_profile_id" in cursor.applied[0]
|
||||
|
||||
|
||||
def test_sdk_matter_references_are_owned_by_migration_57() -> None:
|
||||
from one_memory.memory_server.migrations import MIGRATIONS, sdk_matter_references_ddl
|
||||
|
||||
|
||||
@@ -259,7 +259,7 @@ def test_old_branch_job_executes_its_frozen_revision_after_harness_rewind(tmp_pa
|
||||
):
|
||||
assert request_id == old_job.request_id
|
||||
captured.extend(records)
|
||||
await before_hy_invocation()
|
||||
await before_hy_invocation(True)
|
||||
return SimpleNamespace(raw_ids=(), memory_ids=())
|
||||
|
||||
asyncio.run(
|
||||
|
||||
@@ -1181,7 +1181,7 @@ def test_authority_invalid_task_is_never_reinvoked(tmp_path) -> None:
|
||||
assert retained.last_error_code == "hy_memory_write_verification_authority_invalid"
|
||||
|
||||
|
||||
def test_linked_unknown_is_retried_only_by_the_durable_job(tmp_path) -> None:
|
||||
def test_linked_unknown_is_rerun_only_by_the_durable_job(tmp_path) -> None:
|
||||
from one_memory.memory_server.contracts import BatchIdentity, WriteRequest
|
||||
from one_memory.memory_server.durable_queue import DurableWriteQueue
|
||||
|
||||
@@ -1241,8 +1241,8 @@ def test_linked_unknown_is_retried_only_by_the_durable_job(tmp_path) -> None:
|
||||
cutoff = datetime(2026, 1, 1, 0, 5, tzinfo=UTC)
|
||||
assert store.retry_first_unknown_after_reconcile(older_than=cutoff) == 0
|
||||
assert store.require(task.task_id).status is MemoryTaskStatus.UNKNOWN
|
||||
assert queue.retry_first_unknown_after_reconcile(older_than=cutoff) == 1
|
||||
assert store.require(task.task_id).status is MemoryTaskStatus.PENDING
|
||||
# The durable job is the only execution entry: it re-runs itself.
|
||||
assert queue.recover_unknown() == 1
|
||||
assert queue.get(job.request_id).state == "pending"
|
||||
assert (
|
||||
store.active_window(
|
||||
|
||||
@@ -0,0 +1,875 @@
|
||||
"""A broken model configuration must not turn a backlog into a request storm (#245)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from one_memory.api import create_app
|
||||
from one_memory.config import Settings
|
||||
from one_memory.imports.models import (
|
||||
EvidenceInput,
|
||||
EvidenceOrigin,
|
||||
OriginMetadata,
|
||||
WrittenBy,
|
||||
materialize_write_input,
|
||||
)
|
||||
from one_memory.memory_server.contracts import BatchIdentity, WriteRequest
|
||||
from one_memory.memory_server.durable_queue import (
|
||||
FAILED,
|
||||
PENDING,
|
||||
DurableWriteDrain,
|
||||
DurableWriteQueue,
|
||||
execute_job,
|
||||
)
|
||||
from one_memory.model_backoff import (
|
||||
BACKOFF_CAP_SECONDS,
|
||||
BackedOffModelTransport,
|
||||
ModelBackoff,
|
||||
ModelBackoffRefusal,
|
||||
backoff_seconds,
|
||||
)
|
||||
from one_memory.sdk_write_processor import SdkWriteProcessor, sdk_write_failure_code
|
||||
from one_memory.secrets import InMemorySecretStore
|
||||
|
||||
CHAT = "https://models.example.com/v1/chat/completions"
|
||||
EMBEDDINGS = "https://models.example.com/v1/embeddings"
|
||||
|
||||
|
||||
class _Clock:
|
||||
def __init__(self) -> None:
|
||||
self.now = 0.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
|
||||
def _request(index: int) -> WriteRequest:
|
||||
session = f"sess-{index}"
|
||||
return WriteRequest(
|
||||
internal_session_id=session,
|
||||
batch_identity=BatchIdentity(internal_session_id=session, first_round=1, last_round=5),
|
||||
evidence_ids=[f"e-{index}"],
|
||||
idempotency_key=f"idem-{index}",
|
||||
# Twenty sources, so the drain could claim ten jobs at once.
|
||||
source_id=f"source-{index % 20}",
|
||||
)
|
||||
|
||||
|
||||
async def _never_execute(job_id: str) -> None:
|
||||
raise AssertionError(f"the simulation settles {job_id} itself")
|
||||
|
||||
|
||||
def _send(transport: BackedOffModelTransport, url: str = CHAT) -> int | None:
|
||||
try:
|
||||
response = asyncio.run(transport.handle_async_request(httpx.Request("POST", url)))
|
||||
except ModelBackoffRefusal:
|
||||
return None
|
||||
return response.status_code
|
||||
|
||||
|
||||
def test_the_pause_doubles_from_five_seconds_and_stops_at_three_minutes() -> None:
|
||||
assert [backoff_seconds(level) for level in range(1, 9)] == [
|
||||
5.0,
|
||||
10.0,
|
||||
20.0,
|
||||
40.0,
|
||||
80.0,
|
||||
160.0,
|
||||
180.0,
|
||||
180.0,
|
||||
]
|
||||
assert backoff_seconds(1000) == BACKOFF_CAP_SECONDS == 180.0
|
||||
|
||||
|
||||
def test_an_always_failing_provider_sees_a_bounded_number_of_calls(tmp_path) -> None:
|
||||
"""200 pending jobs against a provider that refuses every one.
|
||||
|
||||
Before the pause the drain claimed the next job the moment the last one
|
||||
failed: hundreds of requests a minute. Now the calls follow the schedule.
|
||||
"""
|
||||
clock = _Clock()
|
||||
backoff = ModelBackoff(clock=clock)
|
||||
calls: list[float] = []
|
||||
|
||||
def provider(_request: httpx.Request) -> httpx.Response:
|
||||
calls.append(clock.now)
|
||||
return httpx.Response(401)
|
||||
|
||||
transport = BackedOffModelTransport(
|
||||
backoff, profile_id="p:1", inner=httpx.MockTransport(provider)
|
||||
)
|
||||
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||
for index in range(200):
|
||||
queue.submit(_request(index))
|
||||
drain = DurableWriteDrain(
|
||||
queue,
|
||||
execute=_never_execute,
|
||||
owner="sim",
|
||||
model_claim_allowance=backoff.allowance,
|
||||
)
|
||||
|
||||
claimed = 0
|
||||
for tick in range(6001):
|
||||
# Ten minutes in tenths of a second, without float drift.
|
||||
clock.now = tick / 10
|
||||
for job_id in drain.claim(limit=10):
|
||||
claimed += 1
|
||||
_send(transport)
|
||||
queue.settle(job_id, state=FAILED, owner="sim", error_code="model_auth_failed")
|
||||
|
||||
# The first claim was taken before anything had failed: two of its
|
||||
# requests reached the provider and the rest were refused locally. After
|
||||
# that one probe per pause: 5, 10, 20, 40, 80, 160, then 180 seconds.
|
||||
assert calls[:2] == [0.0, 0.0]
|
||||
probes = calls[2:]
|
||||
assert probes == pytest.approx([5.0, 15.0, 35.0, 75.0, 155.0, 315.0, 495.0], abs=0.11)
|
||||
gaps = [later - earlier for earlier, later in zip(probes, probes[1:], strict=False)]
|
||||
assert max(gaps) <= BACKOFF_CAP_SECONDS + 0.11
|
||||
assert len(calls) == 9
|
||||
assert claimed == 17
|
||||
with sqlite3.connect(tmp_path / "one-memory.sqlite3") as database:
|
||||
(pending,) = database.execute(
|
||||
"SELECT COUNT(*) FROM memory_write_jobs WHERE state=?", (PENDING,)
|
||||
).fetchone()
|
||||
assert pending == 200 - claimed
|
||||
|
||||
|
||||
def test_a_success_ends_the_pause_at_once() -> None:
|
||||
clock = _Clock()
|
||||
backoff = ModelBackoff(clock=clock)
|
||||
replies = [httpx.Response(503), httpx.Response(503), httpx.Response(200)]
|
||||
transport = BackedOffModelTransport(
|
||||
backoff, profile_id="p:1", inner=httpx.MockTransport(lambda _r: replies.pop(0))
|
||||
)
|
||||
|
||||
assert _send(transport) == 503
|
||||
clock.now = 5.0
|
||||
assert backoff.allowance() == 1
|
||||
assert _send(transport) == 503
|
||||
assert backoff.allowance() == 0
|
||||
assert backoff.state().last_error == "model_server_error"
|
||||
|
||||
clock.now = 15.0
|
||||
assert backoff.allowance() == 1
|
||||
assert _send(transport) == 200
|
||||
assert backoff.allowance() is None
|
||||
assert backoff.state().until is None
|
||||
assert backoff.state().failures == 0
|
||||
|
||||
|
||||
def test_a_working_embedding_endpoint_does_not_clear_a_broken_chat_model() -> None:
|
||||
clock = _Clock()
|
||||
backoff = ModelBackoff(clock=clock)
|
||||
|
||||
def provider(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200 if request.url.path.endswith("/embeddings") else 404)
|
||||
|
||||
transport = BackedOffModelTransport(
|
||||
backoff, profile_id="p:1", inner=httpx.MockTransport(provider)
|
||||
)
|
||||
assert _send(transport) == 404
|
||||
assert _send(transport) == 404
|
||||
# Recall's embeddings still go out while the chat model is refused.
|
||||
assert _send(transport, EMBEDDINGS) == 200
|
||||
assert _send(transport) is None
|
||||
assert backoff.state().last_error == "model_not_found"
|
||||
assert backoff.paused()
|
||||
|
||||
|
||||
def test_claim_allowance_can_pace_embedding_without_pausing_lite_for_chat() -> None:
|
||||
backoff = ModelBackoff(clock=_Clock())
|
||||
backoff.record_failure("model_not_found", "p:1", "chat")
|
||||
assert backoff.allowance(kind="embedding") is None
|
||||
assert backoff.allowance(kind="chat") == 0
|
||||
|
||||
backoff.reset()
|
||||
backoff.record_failure("embedding_service_failed", "p:1", "embedding")
|
||||
assert backoff.allowance(kind="embedding") == 0
|
||||
|
||||
|
||||
def test_an_unreachable_provider_is_paused_like_a_refusing_one() -> None:
|
||||
clock = _Clock()
|
||||
backoff = ModelBackoff(clock=clock)
|
||||
|
||||
def provider(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("name resolution failed", request=request)
|
||||
|
||||
transport = BackedOffModelTransport(
|
||||
backoff, profile_id="p:1", inner=httpx.MockTransport(provider)
|
||||
)
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
_send(transport)
|
||||
assert backoff.state().last_error == "model_unreachable"
|
||||
assert backoff.allowance() == 0
|
||||
|
||||
|
||||
def test_a_request_problem_is_left_to_its_job() -> None:
|
||||
backoff = ModelBackoff(clock=_Clock())
|
||||
transport = BackedOffModelTransport(
|
||||
backoff, profile_id="p:1", inner=httpx.MockTransport(lambda _r: httpx.Response(400))
|
||||
)
|
||||
assert _send(transport) == 400
|
||||
assert backoff.allowance() is None
|
||||
|
||||
|
||||
def test_a_configuration_change_ends_the_pause() -> None:
|
||||
clock = _Clock()
|
||||
backoff = ModelBackoff(clock=clock)
|
||||
for _ in range(6):
|
||||
clock.now += 200.0
|
||||
backoff.record_failure("model_auth_failed", "p:1", "chat")
|
||||
assert backoff.state().until is not None
|
||||
|
||||
backoff.reset()
|
||||
|
||||
assert backoff.allowance() is None
|
||||
assert backoff.state().until is None
|
||||
# A new revision of the profile is a new configuration as well.
|
||||
backoff.record_failure("model_auth_failed", "p:1", "chat")
|
||||
backoff.record_failure("model_auth_failed", "p:2", "chat")
|
||||
assert backoff.state().failures == 1
|
||||
|
||||
|
||||
def test_a_probe_that_never_reports_does_not_hold_the_queue_forever() -> None:
|
||||
clock = _Clock()
|
||||
backoff = ModelBackoff(clock=clock)
|
||||
backoff.record_failure("model_timeout", "p:1", "chat")
|
||||
clock.now = 5.0
|
||||
assert backoff.allowance() == 1
|
||||
assert backoff.allowance() == 0
|
||||
clock.now = 35.0
|
||||
assert backoff.allowance() == 1
|
||||
|
||||
|
||||
def test_memory_processing_shows_the_pause_and_a_profile_save_ends_it(tmp_path) -> None:
|
||||
async def scenario() -> None:
|
||||
app = create_app(
|
||||
Settings(
|
||||
data_dir=tmp_path / "data",
|
||||
source_home=tmp_path,
|
||||
api_token=SecretStr("local-token"),
|
||||
),
|
||||
secret_store=InMemorySecretStore(),
|
||||
)
|
||||
headers = {"Authorization": "Bearer local-token", "Origin": "http://127.0.0.1:5173"}
|
||||
async with (
|
||||
app.router.lifespan_context(app),
|
||||
AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test", headers=headers
|
||||
) as client,
|
||||
):
|
||||
created = await client.post(
|
||||
"/api/v1/model-profiles",
|
||||
json={
|
||||
"name": "Provider",
|
||||
"provider_kind": "openai_compatible",
|
||||
"base_url": "https://models.example.com/v1",
|
||||
"model": "memory-model",
|
||||
"embedding_model": "embedding-model",
|
||||
"embedding_base_url": "https://models.example.com/v1",
|
||||
"api_key": "api-route-secret",
|
||||
},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
backoff: ModelBackoff = app.state.model_backoff
|
||||
backoff.record_failure("model_auth_failed", "p:1", "chat")
|
||||
|
||||
paused = (await client.get("/api/v1/memory-processing")).json()
|
||||
assert paused["llm_backoff_error"] == "model_auth_failed"
|
||||
until = datetime.fromisoformat(paused["llm_backoff_until"])
|
||||
assert until > datetime.now(UTC)
|
||||
assert paused["suspended"] == 0
|
||||
|
||||
saved = await client.patch(
|
||||
f"/api/v1/model-profiles/{created.json()['profile_id']}",
|
||||
json={"expected_revision": created.json()["revision"], "model": "other-model"},
|
||||
)
|
||||
assert saved.status_code == 200
|
||||
|
||||
cleared = (await client.get("/api/v1/memory-processing")).json()
|
||||
assert cleared["llm_backoff_until"] is None
|
||||
assert cleared["llm_backoff_error"] is None
|
||||
assert backoff.allowance() is None
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
# --- Every write retries itself: provider failures wait, others retry 3 times ---
|
||||
|
||||
|
||||
class _FakeSdk:
|
||||
"""Stands in for the SDK write path, with its real replay rule.
|
||||
|
||||
Like `HyMemoryClient`, it keeps one outcome per request id and replays a
|
||||
failed one without calling the model. Every write goes out through the
|
||||
breaker's transport, so the provider sees exactly what MemHub sends.
|
||||
"""
|
||||
|
||||
def __init__(self, transport: BackedOffModelTransport | None = None) -> None:
|
||||
self.transport = transport
|
||||
self.outcomes: dict[str, str] = {}
|
||||
self.written: list[str] = []
|
||||
self.request_ids: list[str] = []
|
||||
self.fail_with: str | None = None
|
||||
|
||||
async def process_sdk_job(self, records, *, request_id, before_hy_invocation):
|
||||
await before_hy_invocation(True)
|
||||
self.request_ids.append(request_id)
|
||||
if request_id in self.outcomes:
|
||||
if self.outcomes[request_id] == "failed":
|
||||
raise RuntimeError("hy_sdk_write_failed")
|
||||
raise AssertionError("a succeeded id is never sent again")
|
||||
if self.fail_with is not None:
|
||||
self.outcomes[request_id] = "failed"
|
||||
raise RuntimeError(self.fail_with)
|
||||
if self.transport is not None:
|
||||
try:
|
||||
response = await self.transport.handle_async_request(httpx.Request("POST", CHAT))
|
||||
except httpx.TransportError:
|
||||
self.outcomes[request_id] = "unknown"
|
||||
raise
|
||||
if response.status_code >= 400:
|
||||
self.outcomes[request_id] = "failed"
|
||||
raise RuntimeError("hy_sdk_write_failed")
|
||||
self.outcomes[request_id] = "succeeded"
|
||||
self.written.extend(record.evidence_id for record in records)
|
||||
return SimpleNamespace(raw_ids=(f"raw-{request_id}",), memory_ids=(f"m-{request_id}",))
|
||||
|
||||
async def inquire_write_outcome(self, request_id):
|
||||
if self.outcomes.get(request_id) != "succeeded":
|
||||
return None
|
||||
return SimpleNamespace(raw_ids=(f"raw-{request_id}",), memory_ids=(f"m-{request_id}",))
|
||||
|
||||
|
||||
class _Evidence:
|
||||
def get(self, evidence_id: str):
|
||||
return SimpleNamespace(evidence_id=evidence_id, source_id="cursor", user_id="local")
|
||||
|
||||
|
||||
def _ordered_request(index: int, *, source: str = "cursor") -> WriteRequest:
|
||||
return WriteRequest(
|
||||
internal_session_id="sess-1",
|
||||
batch_identity=BatchIdentity(
|
||||
internal_session_id="sess-1", first_round=index + 1, last_round=index + 1
|
||||
),
|
||||
evidence_ids=[f"e-{index:03d}"],
|
||||
idempotency_key=f"idem-{index:03d}",
|
||||
source_id=source,
|
||||
event_at=datetime(2026, 10, 1, tzinfo=UTC) + timedelta(minutes=index),
|
||||
)
|
||||
|
||||
|
||||
def _jobs(path) -> list[tuple[str, str, int, int, str | None]]:
|
||||
with sqlite3.connect(path) as database:
|
||||
return database.execute(
|
||||
"SELECT idempotency_key,state,sdk_attempt,retries,last_error "
|
||||
"FROM memory_write_jobs ORDER BY queue_seq"
|
||||
).fetchall()
|
||||
|
||||
|
||||
def test_an_outage_with_200_queued_jobs_ends_with_every_job_written_once_in_order(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""The provider refuses every request for five minutes, then recovers.
|
||||
|
||||
No job is lost, skipped, written twice or left `unknown`; the order is
|
||||
the order of the conversation; the provider sees a handful of probes
|
||||
during the outage, not one request per job.
|
||||
"""
|
||||
clock = _Clock()
|
||||
backoff = ModelBackoff(clock=clock)
|
||||
calls: list[tuple[float, int]] = []
|
||||
|
||||
def provider(_request: httpx.Request) -> httpx.Response:
|
||||
status = 401 if clock.now < 300.0 else 200
|
||||
calls.append((clock.now, status))
|
||||
return httpx.Response(status)
|
||||
|
||||
sdk = _FakeSdk(
|
||||
BackedOffModelTransport(backoff, profile_id="p:1", inner=httpx.MockTransport(provider))
|
||||
)
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
for index in range(200):
|
||||
queue.submit(_ordered_request(index))
|
||||
drain = DurableWriteDrain(
|
||||
queue,
|
||||
execute=_never_execute,
|
||||
owner="outage",
|
||||
model_claim_allowance=backoff.allowance,
|
||||
)
|
||||
|
||||
async def tick() -> None:
|
||||
await asyncio.gather(
|
||||
*(
|
||||
execute_job(
|
||||
job_id,
|
||||
queue=queue,
|
||||
supervisor=sdk,
|
||||
evidence=_Evidence(),
|
||||
owner="outage",
|
||||
model_failures=backoff.attempt,
|
||||
)
|
||||
for job_id in drain.claim(limit=10)
|
||||
)
|
||||
)
|
||||
|
||||
for second in range(800):
|
||||
clock.now = float(second)
|
||||
asyncio.run(tick())
|
||||
|
||||
jobs = _jobs(path)
|
||||
assert [state for _, state, *_ in jobs] == ["completed"] * 200
|
||||
assert sdk.written == [f"e-{index:03d}" for index in range(200)]
|
||||
outage = [at for at, status in calls if status == 401]
|
||||
# One request at 0 s, then one probe per pause: 5, 15, 35, 75, 155 s.
|
||||
assert outage == [0.0, 5.0, 15.0, 35.0, 75.0, 155.0]
|
||||
# The first job was tried seven times; each try had its own SDK id, so
|
||||
# the recovered provider was really called, not a replayed failure.
|
||||
assert jobs[0][2:4] == (6, 0)
|
||||
assert sdk.request_ids[:3] == ["idem-000", "idem-000#r1", "idem-000#r2"]
|
||||
assert all(retries == 0 for *_, retries, _ in jobs)
|
||||
|
||||
|
||||
def test_a_failure_of_the_job_itself_is_retried_three_times_then_skipped(tmp_path) -> None:
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
first = queue.submit(_ordered_request(0))
|
||||
second = queue.submit(_ordered_request(1))
|
||||
sdk = _FakeSdk()
|
||||
drain = DurableWriteDrain(queue, execute=_never_execute, owner="skip")
|
||||
now = datetime.now(UTC)
|
||||
|
||||
def run(at: datetime) -> tuple[str, ...]:
|
||||
claimed = drain.claim(limit=1, now=at)
|
||||
for job_id in claimed:
|
||||
asyncio.run(
|
||||
execute_job(
|
||||
job_id,
|
||||
queue=queue,
|
||||
supervisor=sdk,
|
||||
evidence=_Evidence(),
|
||||
owner="skip",
|
||||
model_failures=ModelBackoff().attempt,
|
||||
)
|
||||
)
|
||||
return claimed
|
||||
|
||||
sdk.fail_with = "model_content_json_parse_failed"
|
||||
assert run(now) == (first.job_id,)
|
||||
# Each retry waits longer: 30 s, 60 s, 120 s. Until then the claim skips
|
||||
# the job, and the job after it in the conversation waits behind it.
|
||||
assert run(now + timedelta(seconds=20)) == ()
|
||||
assert run(now + timedelta(seconds=40)) == (first.job_id,)
|
||||
assert run(now + timedelta(seconds=150)) == (first.job_id,)
|
||||
assert run(now + timedelta(seconds=400)) == (first.job_id,)
|
||||
assert _jobs(path)[0][1:] == ("failed", 3, 3, "model_content_json_parse_failed")
|
||||
assert queue.get("idem-000").error_code == "model_content_json_parse_failed"
|
||||
|
||||
sdk.fail_with = None
|
||||
assert run(now + timedelta(seconds=401)) == (second.job_id,)
|
||||
assert _jobs(path)[1][1] == "completed"
|
||||
assert sdk.request_ids == [
|
||||
"idem-000",
|
||||
"idem-000#r1",
|
||||
"idem-000#r2",
|
||||
"idem-000#r3",
|
||||
"idem-001",
|
||||
]
|
||||
|
||||
|
||||
def test_a_write_interrupted_mid_call_is_rerun_under_a_new_request_id(tmp_path) -> None:
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
record = queue.submit(_ordered_request(0))
|
||||
drain = DurableWriteDrain(queue, execute=_never_execute, owner="crashed")
|
||||
assert drain.claim(limit=1) == (record.job_id,)
|
||||
assert queue.mark_invoked(record.job_id, owner="crashed")
|
||||
# The process stops; on restart the lease has lapsed.
|
||||
with sqlite3.connect(path) as database:
|
||||
database.execute(
|
||||
"UPDATE memory_write_jobs SET lease_expires_at=? WHERE job_id=?",
|
||||
("2000-01-01T00:00:00+00:00", record.job_id),
|
||||
)
|
||||
assert queue.reclaim_expired() == 1
|
||||
assert queue.get("idem-000").state == "unknown"
|
||||
# Still running on its thread in this process: left alone.
|
||||
assert queue.recover_unknown(in_flight=lambda request_id: request_id == "idem-000") == 0
|
||||
|
||||
assert queue.recover_unknown() == 1
|
||||
assert _jobs(path)[0][1:] == ("pending", 1, 1, "durable_write_interrupted")
|
||||
assert queue.recover_unknown() == 0
|
||||
|
||||
sdk = _FakeSdk()
|
||||
later = datetime.now(UTC) + timedelta(seconds=31)
|
||||
rerun = DurableWriteDrain(queue, execute=_never_execute, owner="restarted")
|
||||
assert rerun.claim(limit=1, now=later) == (record.job_id,)
|
||||
asyncio.run(
|
||||
execute_job(
|
||||
record.job_id, queue=queue, supervisor=sdk, evidence=_Evidence(), owner="restarted"
|
||||
)
|
||||
)
|
||||
assert sdk.request_ids == ["idem-000#r1"]
|
||||
assert queue.get("idem-000").state == "completed"
|
||||
|
||||
|
||||
def test_a_write_that_did_land_is_completed_from_its_receipt_not_rerun(tmp_path) -> None:
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
record = queue.submit(_ordered_request(0))
|
||||
with sqlite3.connect(path) as database:
|
||||
database.execute(
|
||||
"UPDATE memory_write_jobs SET state='unknown',hy_invoked=1,"
|
||||
"last_error='durable_work_ceiling_exceeded' WHERE job_id=?",
|
||||
(record.job_id,),
|
||||
)
|
||||
sdk = _FakeSdk()
|
||||
sdk.outcomes["idem-000"] = "succeeded"
|
||||
assert queue.recover_unknown() == 1
|
||||
drain = DurableWriteDrain(queue, execute=_never_execute, owner="landed")
|
||||
assert drain.claim(limit=1, now=datetime.now(UTC) + timedelta(seconds=31)) == (record.job_id,)
|
||||
asyncio.run(
|
||||
execute_job(
|
||||
record.job_id, queue=queue, supervisor=sdk, evidence=_Evidence(), owner="landed"
|
||||
)
|
||||
)
|
||||
assert sdk.request_ids == []
|
||||
completed = queue.get("idem-000")
|
||||
assert completed.state == "completed"
|
||||
assert completed.sdk_memory_ids == ("m-idem-000",)
|
||||
|
||||
|
||||
def test_unknown_jobs_from_before_the_upgrade_are_handled_once(tmp_path) -> None:
|
||||
from one_memory.memory_tasks import NON_RETRYABLE_POST_WRITE_CODES
|
||||
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
plain = queue.submit(_ordered_request(0, source="a"))
|
||||
verified = queue.submit(_ordered_request(1, source="b"))
|
||||
looping = queue.submit(_ordered_request(2, source="c"))
|
||||
with sqlite3.connect(path) as database:
|
||||
for job, code, retries in (
|
||||
(plain, "hy_sdk_write_failed", 0),
|
||||
(verified, NON_RETRYABLE_POST_WRITE_CODES[0], 0),
|
||||
(looping, "durable_write_interrupted", 3),
|
||||
):
|
||||
database.execute(
|
||||
"UPDATE memory_write_jobs SET state='unknown',hy_invoked=1,attempts=1,"
|
||||
"last_error=?,retries=? WHERE job_id=?",
|
||||
(code, retries, job.job_id),
|
||||
)
|
||||
|
||||
assert queue.recover_unknown() == 3
|
||||
assert queue.recover_unknown() == 0
|
||||
# Reopening the queue (a restart) changes nothing either.
|
||||
assert DurableWriteQueue(path).recover_unknown() == 0
|
||||
states = {key: (state, attempt) for key, state, attempt, _, _ in _jobs(path)}
|
||||
assert states == {
|
||||
"idem-000": ("pending", 1),
|
||||
# The write landed against an authority Core could not verify.
|
||||
"idem-001": ("failed", 0),
|
||||
# A crash loop ends instead of spinning.
|
||||
"idem-002": ("failed", 0),
|
||||
}
|
||||
|
||||
|
||||
def test_concurrent_writes_are_all_charged_with_a_provider_failure() -> None:
|
||||
"""The breaker cannot tell whose request failed, so every write in flight waits.
|
||||
|
||||
A write the provider did fail must never spend its retries on the outage;
|
||||
a write that failed beside it for its own reason waits for the provider
|
||||
once, and is retried with the rest.
|
||||
"""
|
||||
backoff = ModelBackoff(clock=_Clock())
|
||||
with backoff.attempt() as first, backoff.attempt() as second:
|
||||
backoff.record_failure("model_server_error", "p:1", "chat")
|
||||
with backoff.attempt() as later:
|
||||
pass
|
||||
assert (first.failure, second.failure, later.failure) == (
|
||||
"model_server_error",
|
||||
"model_server_error",
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def test_the_manual_retry_endpoint_is_gone(tmp_path) -> None:
|
||||
async def scenario() -> int:
|
||||
app = create_app(
|
||||
Settings(
|
||||
data_dir=tmp_path / "data",
|
||||
source_home=tmp_path,
|
||||
api_token=SecretStr("local-token"),
|
||||
),
|
||||
secret_store=InMemorySecretStore(),
|
||||
)
|
||||
async with (
|
||||
app.router.lifespan_context(app),
|
||||
AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": "Bearer local-token"},
|
||||
) as client,
|
||||
):
|
||||
response = await client.post(
|
||||
"/api/v1/memory-processing/retry-unknown", json={"confirm": True}
|
||||
)
|
||||
return response.status_code
|
||||
|
||||
assert asyncio.run(scenario()) in {404, 405}
|
||||
|
||||
|
||||
# --- hy-memory 2.0.13: a model failure is `success: false`, never a silent success ---
|
||||
|
||||
|
||||
class _Sdk2013Client:
|
||||
"""`HyMemoryClient.add` as hy-memory 2.0.13 answers it.
|
||||
|
||||
Each write makes one model call through MemHub's transport, so the
|
||||
breaker sees what the provider answered. The SDK turns a failed or
|
||||
unusable model reply into `success: false` with its stable codes; the
|
||||
shape of those results is the SDK's own (`_mark_reconcile_failure`,
|
||||
`CodingWriter`).
|
||||
"""
|
||||
|
||||
def __init__(self, transport: BackedOffModelTransport) -> None:
|
||||
self.transport = transport
|
||||
self.reply = "[]"
|
||||
self.messages: list[list[dict[str, str]]] = []
|
||||
|
||||
def add(self, messages, **_kwargs):
|
||||
self.messages.append(messages)
|
||||
coding = any(message["role"] == "tool" for message in messages)
|
||||
try:
|
||||
response = asyncio.run(self.transport.handle_async_request(httpx.Request("POST", CHAT)))
|
||||
except httpx.TransportError:
|
||||
return self._failed(coding, "LLM_UNAVAILABLE", "LLM_ERROR")
|
||||
if response.status_code >= 400:
|
||||
return self._failed(coding, "LLM_UNAVAILABLE", "LLM_ERROR")
|
||||
if not self.reply:
|
||||
return self._failed(coding, "EMPTY_RESPONSE", "EMPTY_RESPONSE")
|
||||
if not self.reply.startswith("["):
|
||||
return self._failed(coding, "JSON_PARSE_FAILED", "JSON_PARSE_FAILED")
|
||||
return {"success": True, "memory_ids": ["m-1"]}
|
||||
|
||||
@staticmethod
|
||||
def _failed(coding: bool, reconcile_cause: str, coding_code: str) -> dict[str, object]:
|
||||
if coding:
|
||||
return {"success": False, "agent_error_code": coding_code}
|
||||
return {
|
||||
"success": False,
|
||||
"error_code": 502,
|
||||
"agent_error_code": "RECONCILE_FAILED",
|
||||
"reconcile_error_code": reconcile_cause,
|
||||
}
|
||||
|
||||
|
||||
class _Sdk2013Handle:
|
||||
mode = "pro"
|
||||
|
||||
def __init__(self, client: _Sdk2013Client) -> None:
|
||||
self.sdk = client
|
||||
|
||||
async def client(self) -> _Sdk2013Client:
|
||||
return self.sdk
|
||||
|
||||
|
||||
class _Sdk2013Supervisor:
|
||||
def __init__(self, client: _Sdk2013Client) -> None:
|
||||
self.processor = SdkWriteProcessor(_Sdk2013Handle(client)) # type: ignore[arg-type]
|
||||
|
||||
async def process_sdk_job(self, records, *, request_id, before_hy_invocation):
|
||||
return await self.processor.process_sdk_job(
|
||||
records, request_id=request_id, before_hy_invocation=before_hy_invocation
|
||||
)
|
||||
|
||||
async def inquire_write_outcome(self, _request_id):
|
||||
return None
|
||||
|
||||
|
||||
class _Turns:
|
||||
"""Evidence for one conversation batch: a chat turn, or a coding turn with tools."""
|
||||
|
||||
def __init__(self, roles: tuple[str, ...]) -> None:
|
||||
self.records = {
|
||||
f"e-{index}": materialize_write_input(
|
||||
EvidenceInput(
|
||||
user_id="local",
|
||||
source_id="cursor",
|
||||
conversation_id="sess-1",
|
||||
message_id=f"msg-{index}",
|
||||
role=role,
|
||||
written_by=WrittenBy.PERSON,
|
||||
created_at=datetime(2026, 10, 1, tzinfo=UTC),
|
||||
verbatim=f"{role} says something worth keeping ({index})",
|
||||
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
|
||||
)
|
||||
)
|
||||
for index, role in enumerate(roles)
|
||||
}
|
||||
|
||||
def get(self, evidence_id: str):
|
||||
return self.records[evidence_id]
|
||||
|
||||
def request(self) -> WriteRequest:
|
||||
return WriteRequest(
|
||||
internal_session_id="sess-1",
|
||||
batch_identity=BatchIdentity(internal_session_id="sess-1", first_round=1, last_round=1),
|
||||
evidence_ids=list(self.records),
|
||||
idempotency_key="idem-000",
|
||||
source_id="cursor",
|
||||
)
|
||||
|
||||
|
||||
def _outage_then_recovery(tmp_path, roles: tuple[str, ...], failure) -> tuple[list, list]:
|
||||
clock = _Clock()
|
||||
backoff = ModelBackoff(clock=clock)
|
||||
calls: list[float] = []
|
||||
|
||||
def provider(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(clock.now)
|
||||
if clock.now < 60.0:
|
||||
return failure(request)
|
||||
return httpx.Response(200)
|
||||
|
||||
client = _Sdk2013Client(
|
||||
BackedOffModelTransport(backoff, profile_id="p:1", inner=httpx.MockTransport(provider))
|
||||
)
|
||||
turns = _Turns(roles)
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
queue.submit(turns.request())
|
||||
drain = DurableWriteDrain(
|
||||
queue, execute=_never_execute, owner="outage", model_claim_allowance=backoff.allowance
|
||||
)
|
||||
seen: list[tuple[str, int, int, str | None]] = []
|
||||
for second in range(120):
|
||||
clock.now = float(second)
|
||||
for job_id in drain.claim(limit=1):
|
||||
asyncio.run(
|
||||
execute_job(
|
||||
job_id,
|
||||
queue=queue,
|
||||
supervisor=_Sdk2013Supervisor(client),
|
||||
evidence=turns,
|
||||
owner="outage",
|
||||
model_failures=backoff.attempt,
|
||||
)
|
||||
)
|
||||
seen.append(_jobs(path)[0][1:])
|
||||
assert all(len(sent) == len(roles) for sent in client.messages)
|
||||
return calls, seen
|
||||
|
||||
|
||||
def _timeout(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ReadTimeout("provider timed out", request=request)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("failure", "code"),
|
||||
[
|
||||
(lambda _request: httpx.Response(401), "model_auth_failed"),
|
||||
(lambda _request: httpx.Response(503), "model_server_error"),
|
||||
(_timeout, "model_timeout"),
|
||||
],
|
||||
ids=["401", "503", "timeout"],
|
||||
)
|
||||
def test_a_reconcile_model_failure_waits_for_the_provider_and_completes_after_it(
|
||||
tmp_path, failure, code
|
||||
) -> None:
|
||||
"""hy-memory 2.0.13 fails the write when reconcile cannot reach its model.
|
||||
|
||||
The breaker saw the provider fail, so the job waits in place without
|
||||
spending a retry, is tried once per pause, and lands after recovery.
|
||||
"""
|
||||
calls, seen = _outage_then_recovery(tmp_path, ("user", "assistant"), failure)
|
||||
assert calls == [0.0, 5.0, 15.0, 35.0, 75.0]
|
||||
assert seen[:-1] == [("pending", attempt, 0, code) for attempt in range(1, 5)]
|
||||
assert seen[-1] == ("completed", 4, 0, None)
|
||||
|
||||
|
||||
def test_a_coding_batch_whose_extractor_fails_at_the_provider_waits_like_chat(tmp_path) -> None:
|
||||
"""Tool turns go down the SDK's coding path; its `LLM_ERROR` waits the same way."""
|
||||
calls, seen = _outage_then_recovery(
|
||||
tmp_path, ("user", "assistant", "tool", "assistant"), lambda _request: httpx.Response(502)
|
||||
)
|
||||
assert calls == [0.0, 5.0, 15.0, 35.0, 75.0]
|
||||
assert seen[:-1] == [("pending", attempt, 0, "model_server_error") for attempt in range(1, 5)]
|
||||
assert seen[-1] == ("completed", 4, 0, None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("reply", "code"),
|
||||
[
|
||||
("", "hy_sdk_reconcile_empty_response"),
|
||||
("I am not sure what you mean.", "hy_sdk_reconcile_json_parse_failed"),
|
||||
],
|
||||
ids=["empty", "unparseable"],
|
||||
)
|
||||
def test_a_reconcile_reply_the_sdk_cannot_use_is_retried_then_failed_with_its_cause(
|
||||
tmp_path, reply, code
|
||||
) -> None:
|
||||
"""HTTP 200 with nothing usable is not a provider failure: 3 retries, then `failed`.
|
||||
|
||||
The job's `error_code` names the SDK's cause instead of a bare
|
||||
`hy_sdk_write_failed`, and the provider is never paused.
|
||||
"""
|
||||
backoff = ModelBackoff(clock=_Clock())
|
||||
client = _Sdk2013Client(
|
||||
BackedOffModelTransport(
|
||||
backoff, profile_id="p:1", inner=httpx.MockTransport(lambda _r: httpx.Response(200))
|
||||
)
|
||||
)
|
||||
client.reply = reply
|
||||
turns = _Turns(("user", "assistant"))
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
queue.submit(turns.request())
|
||||
drain = DurableWriteDrain(queue, execute=_never_execute, owner="bad-reply")
|
||||
now = datetime.now(UTC)
|
||||
for offset in (0, 40, 150, 400):
|
||||
for job_id in drain.claim(limit=1, now=now + timedelta(seconds=offset)):
|
||||
asyncio.run(
|
||||
execute_job(
|
||||
job_id,
|
||||
queue=queue,
|
||||
supervisor=_Sdk2013Supervisor(client),
|
||||
evidence=turns,
|
||||
owner="bad-reply",
|
||||
model_failures=backoff.attempt,
|
||||
)
|
||||
)
|
||||
assert _jobs(path)[0][1:] == ("failed", 3, 3, code)
|
||||
assert queue.get("idem-000").error_code == code
|
||||
assert len(client.messages) == 4
|
||||
assert backoff.allowance() is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("result", "code"),
|
||||
[
|
||||
(
|
||||
{"agent_error_code": "RECONCILE_FAILED", "reconcile_error_code": "LLM_UNAVAILABLE"},
|
||||
"hy_sdk_reconcile_llm_unavailable",
|
||||
),
|
||||
(
|
||||
{"agent_error_code": "RECONCILE_FAILED", "reconcile_error_code": "RECONCILE_EXCEPTION"},
|
||||
"hy_sdk_reconcile_exception",
|
||||
),
|
||||
({"agent_error_code": "RECONCILE_FAILED"}, "hy_sdk_reconcile_failed"),
|
||||
({"agent_error_code": "EMBEDDING_FAILED"}, "hy_sdk_embedding_failed"),
|
||||
({"agent_error_code": "JSON_PARSE_FAILED"}, "hy_sdk_json_parse_failed"),
|
||||
({"agent_error_code": "provider said: bad key sk-123"}, "hy_sdk_write_failed"),
|
||||
({"agent_error_code": 502}, "hy_sdk_write_failed"),
|
||||
({}, "hy_sdk_write_failed"),
|
||||
],
|
||||
)
|
||||
def test_a_failed_sdk_write_keeps_only_the_sdks_stable_code(result, code) -> None:
|
||||
assert sdk_write_failure_code({"success": False, **result}) == code
|
||||
@@ -35,6 +35,12 @@ def test_profile_uses_one_model_and_keeps_key_out_of_database(tmp_path) -> None:
|
||||
)
|
||||
|
||||
assert profile.has_api_key is True
|
||||
assert profile.embedding_provider_kind is ProviderKind.LOCAL_ONNX
|
||||
assert profile.embedding_model == "bge-small-zh-v1.5"
|
||||
assert profile.embedding_dimensions == 512
|
||||
assert profile.timeout_seconds == 240
|
||||
assert profile.max_concurrency == 4
|
||||
assert profile.context_window is None
|
||||
assert "api_key" not in profile.model_dump()
|
||||
assert API_KEY.encode() not in database.read_bytes()
|
||||
assert secrets.resolve(store.secret_ref(profile.profile_id) or "") == API_KEY
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"""Onboarding endpoints manage UI state without touching SDK memory."""
|
||||
|
||||
import asyncio
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from one_memory.api import create_app
|
||||
from one_memory.config import Settings
|
||||
from one_memory.onboarding import OnboardingStore
|
||||
|
||||
|
||||
def _app(tmp_path: Path):
|
||||
@@ -63,13 +67,29 @@ def test_reset_returns_to_first_run_without_touching_sdk_data(tmp_path: Path) ->
|
||||
sdk_marker = tmp_path / "data" / "hy-memory" / "governed-v2" / "memory.marker"
|
||||
sdk_marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
sdk_marker.write_text("keep-sdk-memory", encoding="utf-8")
|
||||
progress = await client.patch(
|
||||
"/api/v1/onboarding/progress",
|
||||
json={
|
||||
"mode_intent": "pro",
|
||||
"setup_step": "model",
|
||||
"model_profile_id": "9be6d465-4cf2-4c72-93f7-15f3c012bc80",
|
||||
},
|
||||
)
|
||||
assert progress.status_code == 200
|
||||
|
||||
assert (await client.post("/api/v1/onboarding/complete")).status_code == 200
|
||||
completed = await client.post("/api/v1/onboarding/complete")
|
||||
assert completed.status_code == 200
|
||||
assert completed.json()["mode_intent"] is None
|
||||
assert completed.json()["setup_step"] is None
|
||||
assert completed.json()["model_profile_id"] is None
|
||||
reset = await client.post("/api/v1/onboarding/reset")
|
||||
|
||||
assert reset.status_code == 200
|
||||
assert reset.json()["status"] == "not_started"
|
||||
assert reset.json()["completed_at"] is None
|
||||
assert reset.json()["mode_intent"] is None
|
||||
assert reset.json()["setup_step"] is None
|
||||
assert reset.json()["model_profile_id"] is None
|
||||
assert (await client.get("/api/v1/onboarding")).json()["status"] == "not_started"
|
||||
assert sdk_marker.read_text(encoding="utf-8") == "keep-sdk-memory"
|
||||
|
||||
@@ -92,6 +112,7 @@ def test_sdk_unavailability_cannot_block_onboarding_completion(tmp_path: Path) -
|
||||
async def scenario() -> None:
|
||||
app = _app(tmp_path)
|
||||
async with app.router.lifespan_context(app), _client(app) as client:
|
||||
|
||||
async def unavailable() -> dict[str, object]:
|
||||
raise RuntimeError("hy_sdk_unavailable")
|
||||
|
||||
@@ -102,3 +123,197 @@ def test_sdk_unavailability_cannot_block_onboarding_completion(tmp_path: Path) -
|
||||
assert finished.json()["status"] == "completed"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_progress_patch_and_put_round_trip_only_resumable_choices(tmp_path: Path) -> None:
|
||||
async def scenario() -> None:
|
||||
app = _app(tmp_path)
|
||||
async with app.router.lifespan_context(app), _client(app) as client:
|
||||
patched = await client.patch(
|
||||
"/api/v1/onboarding/progress",
|
||||
json={"mode_intent": "pro", "setup_step": "mode"},
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
assert patched.json()["mode_intent"] == "pro"
|
||||
assert patched.json()["setup_step"] == "mode"
|
||||
assert patched.json()["model_profile_id"] is None
|
||||
|
||||
profile_id = "9be6d465-4cf2-4c72-93f7-15f3c012bc80"
|
||||
replaced = await client.put(
|
||||
"/api/v1/onboarding/progress",
|
||||
json={"setup_step": "model", "model_profile_id": profile_id},
|
||||
)
|
||||
assert replaced.status_code == 200
|
||||
assert replaced.json()["mode_intent"] == "pro"
|
||||
assert replaced.json()["setup_step"] == "model"
|
||||
assert replaced.json()["model_profile_id"] == profile_id
|
||||
assert (await client.get("/api/v1/onboarding")).json() == replaced.json()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_progress_rejects_unknown_or_invalid_fields(tmp_path: Path) -> None:
|
||||
async def scenario() -> None:
|
||||
app = _app(tmp_path)
|
||||
async with app.router.lifespan_context(app), _client(app) as client:
|
||||
secret = await client.patch(
|
||||
"/api/v1/onboarding/progress",
|
||||
json={"mode_intent": "lite", "api_key": "must-not-be-stored"},
|
||||
)
|
||||
invalid_id = await client.put(
|
||||
"/api/v1/onboarding/progress",
|
||||
json={"model_profile_id": "not-a-profile-id"},
|
||||
)
|
||||
invalid_mode = await client.patch(
|
||||
"/api/v1/onboarding/progress",
|
||||
json={"mode_intent": "ultra"},
|
||||
)
|
||||
|
||||
assert secret.status_code == 422
|
||||
assert "must-not-be-stored" not in secret.text
|
||||
assert invalid_id.status_code == 422
|
||||
assert invalid_mode.status_code == 422
|
||||
state = (await client.get("/api/v1/onboarding")).json()
|
||||
assert state["mode_intent"] is None
|
||||
assert state["model_profile_id"] is None
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_progress_row_adapter_accepts_mysql_tuple_shape() -> None:
|
||||
started_at = datetime(2026, 11, 4, 1, 0, tzinfo=UTC)
|
||||
updated_at = datetime(2026, 11, 4, 1, 1)
|
||||
state = OnboardingStore._to_state(
|
||||
(
|
||||
1,
|
||||
"importing",
|
||||
'["cursor"]',
|
||||
started_at,
|
||||
None,
|
||||
updated_at,
|
||||
"lite",
|
||||
"review",
|
||||
None,
|
||||
),
|
||||
mysql=True,
|
||||
)
|
||||
|
||||
assert state.selected_source_ids == ("cursor",)
|
||||
assert state.started_at == started_at
|
||||
assert state.mode_intent == "lite"
|
||||
assert state.setup_step == "review"
|
||||
|
||||
|
||||
def test_progress_row_adapter_accepts_real_sqlite_row_and_text_datetime() -> None:
|
||||
with sqlite3.connect(":memory:") as connection:
|
||||
connection.row_factory = sqlite3.Row
|
||||
row = connection.execute(
|
||||
"SELECT 1 AS singleton, 'importing' AS status, '[\"cursor\"]' "
|
||||
"AS selected_source_ids_json, '2026-11-04T01:00:00+00:00' AS started_at, "
|
||||
"NULL AS completed_at, '2026-11-04T01:01:00+00:00' AS updated_at, "
|
||||
"'pro' AS mode_intent, 'model' AS setup_step, "
|
||||
"'9be6d465-4cf2-4c72-93f7-15f3c012bc80' AS model_profile_id"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
|
||||
state = OnboardingStore._to_state(row, mysql=False)
|
||||
|
||||
assert state.started_at == datetime(2026, 11, 4, 1, 0, tzinfo=UTC)
|
||||
assert state.mode_intent == "pro"
|
||||
assert state.setup_step == "model"
|
||||
|
||||
|
||||
def test_progress_row_adapter_rejects_wrong_length() -> None:
|
||||
with pytest.raises(ValueError, match="onboarding_row_shape_invalid"):
|
||||
OnboardingStore._to_state((1, "importing"), mysql=True)
|
||||
|
||||
|
||||
def test_progress_row_adapter_rejects_wrong_row_types() -> None:
|
||||
values = (
|
||||
1,
|
||||
"importing",
|
||||
"[]",
|
||||
None,
|
||||
None,
|
||||
datetime(2026, 11, 4, 1, 1),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
with pytest.raises(ValueError, match="onboarding_row_shape_invalid"):
|
||||
OnboardingStore._to_state(list(values), mysql=True)
|
||||
with pytest.raises(ValueError, match="onboarding_row_shape_invalid"):
|
||||
OnboardingStore._to_state(values, mysql=False)
|
||||
|
||||
|
||||
def test_progress_row_adapter_rejects_wrong_engine_datetime_shape() -> None:
|
||||
with pytest.raises(ValueError, match="onboarding_datetime_invalid"):
|
||||
OnboardingStore._to_state(
|
||||
(
|
||||
1,
|
||||
"importing",
|
||||
"[]",
|
||||
"2026-11-04T01:00:00+00:00",
|
||||
None,
|
||||
datetime(2026, 11, 4, 1, 1),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
mysql=True,
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_sqlite_upgrade_does_not_record_progress_migration(tmp_path: Path) -> None:
|
||||
database = tmp_path / "old.sqlite3"
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO schema_migrations VALUES (61, 'write_job_retries', '2026-11-03');
|
||||
CREATE TABLE onboarding_state (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
status TEXT NOT NULL,
|
||||
selected_source_ids_json TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
OnboardingStore(database)
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
recorded = connection.execute(
|
||||
"SELECT name FROM schema_migrations WHERE version=62"
|
||||
).fetchone()
|
||||
columns = {str(row[1]) for row in connection.execute("PRAGMA table_info(onboarding_state)")}
|
||||
assert recorded is None
|
||||
assert {"mode_intent", "setup_step", "model_profile_id"} <= columns
|
||||
|
||||
|
||||
def test_progress_rejects_updates_after_completion(tmp_path: Path) -> None:
|
||||
async def scenario() -> None:
|
||||
app = _app(tmp_path)
|
||||
async with app.router.lifespan_context(app), _client(app) as client:
|
||||
assert (await client.post("/api/v1/onboarding/complete")).status_code == 200
|
||||
|
||||
populated = await client.patch(
|
||||
"/api/v1/onboarding/progress",
|
||||
json={"mode_intent": "lite"},
|
||||
)
|
||||
empty = await client.put("/api/v1/onboarding/progress", json={})
|
||||
|
||||
assert populated.status_code == 409
|
||||
assert populated.json()["detail"] == "onboarding_completed"
|
||||
assert empty.status_code == 409
|
||||
state = (await client.get("/api/v1/onboarding")).json()
|
||||
assert state["status"] == "completed"
|
||||
assert state["mode_intent"] is None
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -0,0 +1,237 @@
|
||||
"""MemHub's raw search against the hy-memory wheel it pins, not the repo source.
|
||||
|
||||
The suite's pythonpath puts the repository root first, so `import hy_memory`
|
||||
in a test reads the repo's `hy_memory/`, which can be ahead of the pinned
|
||||
release. Search kwargs the release does not accept then pass every test and
|
||||
fail every raw search in a packaged build (a `statuses` argument did exactly
|
||||
that against 2.0.13). These tests run in a child interpreter started with
|
||||
`-I`, so `hy_memory` resolves to the wheel installed in the Core venv, and
|
||||
drive the real routes over a real lite SDK client with a deterministic local
|
||||
embedder (no model, no network, no ONNX assets).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.test_sdk_api_surface import SDK_SEARCH_PARAMETERS
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_CHILD = textwrap.dedent(
|
||||
r'''
|
||||
import asyncio
|
||||
import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from importlib.metadata import version
|
||||
from pathlib import Path
|
||||
|
||||
import hy_memory
|
||||
from hy_memory import HyMemoryClient
|
||||
|
||||
from one_memory.hy_runtime_config import HY_MEMORY_VERSION
|
||||
|
||||
home = Path(sys.argv[1])
|
||||
wheel = Path(hy_memory.__file__).resolve()
|
||||
assert "site-packages" in wheel.parts, f"not the installed wheel: {wheel}"
|
||||
assert version("hy-memory") == HY_MEMORY_VERSION, version("hy-memory")
|
||||
parameters = sorted(
|
||||
name
|
||||
for name, parameter in inspect.signature(HyMemoryClient.search).parameters.items()
|
||||
if name not in {"self", "query"} and parameter.kind is not inspect.Parameter.VAR_KEYWORD
|
||||
)
|
||||
var_keyword = any(
|
||||
parameter.kind is inspect.Parameter.VAR_KEYWORD
|
||||
for parameter in inspect.signature(HyMemoryClient.search).parameters.values()
|
||||
)
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
import one_memory.api as api
|
||||
from one_memory.api import create_app
|
||||
from one_memory.config import Settings
|
||||
from one_memory.embedding_assets import BUILTIN_EMBEDDING_DIMENSIONS
|
||||
from one_memory.hy_sdk_runtime import (
|
||||
LocalOnnxEmbeddingService,
|
||||
SdkClientHandle,
|
||||
builtin_retrieval_snapshot,
|
||||
)
|
||||
from one_memory.secrets import InMemorySecretStore
|
||||
|
||||
WORD = re.compile(r"\w+")
|
||||
|
||||
class HashedEmbedder:
|
||||
"""Bag of words and CJK bigrams, hashed: texts sharing words are near."""
|
||||
|
||||
dimensions = BUILTIN_EMBEDDING_DIMENSIONS
|
||||
|
||||
def encode(self, texts):
|
||||
vectors = []
|
||||
for text in texts:
|
||||
vector = [0.0] * self.dimensions
|
||||
for word in WORD.findall(text.casefold()):
|
||||
grams = [word]
|
||||
if not word.isascii():
|
||||
grams += [word[i : i + 2] for i in range(len(word) - 1)]
|
||||
for gram in grams:
|
||||
digest = hashlib.sha256(gram.encode()).digest()
|
||||
vector[int.from_bytes(digest[:4], "big") % self.dimensions] += 1.0
|
||||
norm = math.sqrt(sum(value * value for value in vector)) or 1.0
|
||||
vectors.append([value / norm for value in vector])
|
||||
return vectors
|
||||
|
||||
LocalOnnxEmbeddingService._embedder = HashedEmbedder()
|
||||
|
||||
async def lite_has_no_model(_app):
|
||||
return False
|
||||
|
||||
api._recall_model_ready = lite_has_no_model
|
||||
|
||||
async def main():
|
||||
settings = Settings(
|
||||
data_dir=home / "data",
|
||||
source_home=home,
|
||||
api_token=SecretStr("pinned-sdk"),
|
||||
imports_enabled=False,
|
||||
automatic_memory_processing=False,
|
||||
live_sync_enabled=False,
|
||||
)
|
||||
app = create_app(settings, secret_store=InMemorySecretStore())
|
||||
app.state.memory_mode = lambda: "lite"
|
||||
handle = SdkClientHandle(
|
||||
builtin_retrieval_snapshot(),
|
||||
api_key=None,
|
||||
data_dir=home / "data",
|
||||
mode="lite",
|
||||
)
|
||||
result = {"parameters": parameters, "var_keyword": var_keyword}
|
||||
async with app.router.lifespan_context(app):
|
||||
app.state.sdk_client_handle = handle
|
||||
client = await handle.client()
|
||||
for session, messages in (
|
||||
(
|
||||
"s-hike",
|
||||
[
|
||||
("user", "我叫小林"),
|
||||
("assistant", "你好小林"),
|
||||
("user", "I love hiking mountain trails at dawn"),
|
||||
("assistant", "Noted, early hikes."),
|
||||
],
|
||||
),
|
||||
(
|
||||
"s-tea",
|
||||
[
|
||||
("user", "my favourite drink is green tea"),
|
||||
("assistant", "Green tea, noted."),
|
||||
],
|
||||
),
|
||||
):
|
||||
written = client.add(
|
||||
[{"role": role, "content": content} for role, content in messages],
|
||||
user_id="local",
|
||||
source="hermes",
|
||||
session_id=session,
|
||||
)
|
||||
assert written.get("success", True), written
|
||||
listed = client.list_memories(user_id="local", layers=["l1_raw"], limit=10)
|
||||
rows = listed.get("vdb", listed)["memories"]
|
||||
tea = next(row for row in rows if "green tea" in row["content"])
|
||||
# What pro leaves behind: the raw it already processed is shadow.
|
||||
client._loop_thread.run(
|
||||
client._vector_store.update_payload(tea["memory_id"], {"status": "shadow"})
|
||||
)
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": "Bearer pinned-sdk"},
|
||||
) as http:
|
||||
for name, body in (
|
||||
("hike_turn", {"query": "hiking trails mountain", "raw_unit": "turn"}),
|
||||
("tea_turn", {"query": "favourite green tea drink", "raw_unit": "turn"}),
|
||||
("tea_raw", {"query": "favourite green tea drink"}),
|
||||
):
|
||||
response = await http.post(
|
||||
"/api/v2/context/search", json={"kinds": ["raw"], "limit": 5, **body}
|
||||
)
|
||||
result[name] = {"status": response.status_code, "body": response.json()}
|
||||
response = await http.post(
|
||||
"/api/v1/recall", json={"query": "favourite green tea drink"}
|
||||
)
|
||||
result["recall"] = {"status": response.status_code, "body": response.json()}
|
||||
await handle.close()
|
||||
print("RESULT " + json.dumps(result, ensure_ascii=False))
|
||||
|
||||
asyncio.run(main())
|
||||
'''
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pinned(tmp_path_factory) -> dict:
|
||||
root = tmp_path_factory.mktemp("pinned-sdk")
|
||||
script = root / "scripts" / "child.py"
|
||||
script.parent.mkdir()
|
||||
script.write_text(_CHILD, encoding="utf-8")
|
||||
home = root / "home"
|
||||
home.mkdir()
|
||||
environment = {
|
||||
**os.environ,
|
||||
"ANONYMIZED_TELEMETRY": "False",
|
||||
"ONE_MEMORY_DATA_DIR": str(home / "data"),
|
||||
}
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-I", str(script), str(home)],
|
||||
cwd=home,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=240,
|
||||
check=False,
|
||||
)
|
||||
assert completed.returncode == 0, completed.stderr[-4000:]
|
||||
[line] = [line for line in completed.stdout.splitlines() if line.startswith("RESULT ")]
|
||||
return json.loads(line.removeprefix("RESULT "))
|
||||
|
||||
|
||||
def test_the_fake_sdk_accepts_exactly_the_pinned_search_parameters(pinned) -> None:
|
||||
# The route tests' stand-in rejects what the pinned SDK rejects.
|
||||
assert pinned["var_keyword"] is False
|
||||
assert sorted(SDK_SEARCH_PARAMETERS) == pinned["parameters"]
|
||||
|
||||
|
||||
def test_lite_turn_search_returns_semantic_hits_without_the_literal_query(pinned) -> None:
|
||||
hike = pinned["hike_turn"]
|
||||
assert hike["status"] == 200, hike
|
||||
items = hike["body"]["items"]
|
||||
assert items, hike["body"]
|
||||
top = items[0]
|
||||
# No turn contains "hiking trails mountain"; the hit still comes back,
|
||||
# shown by its closest turn and carrying the hit's score.
|
||||
assert "hiking mountain trails" in top["content"]
|
||||
assert "小林" not in top["content"]
|
||||
assert top["turn_index"] == 1 and top["turn_count"] == 2
|
||||
assert [item["score"] for item in items] == sorted(
|
||||
(item["score"] for item in items), reverse=True
|
||||
)
|
||||
|
||||
|
||||
def test_lite_search_and_recall_include_shadow_raws(pinned) -> None:
|
||||
for name in ("tea_turn", "tea_raw"):
|
||||
result = pinned[name]
|
||||
assert result["status"] == 200, result
|
||||
assert any("green tea" in item["content"] for item in result["body"]["items"]), result
|
||||
recall = pinned["recall"]
|
||||
assert recall["status"] == 200, recall
|
||||
assert recall["body"]["mode"] == "raw"
|
||||
assert any("green tea" in hit["content"] for hit in recall["body"]["hits"]), recall
|
||||
@@ -189,7 +189,11 @@ def test_first_import_gate_waits_for_every_selected_history_job() -> None:
|
||||
app = SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
onboarding_store=SimpleNamespace(get=lambda: state),
|
||||
import_job_store=SimpleNamespace(list_jobs=lambda: jobs),
|
||||
import_job_store=SimpleNamespace(
|
||||
find_jobs=lambda *, user_id, operation: [
|
||||
job for job in jobs if job.user_id == user_id and job.operation == operation
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -259,3 +259,52 @@ def test_recall_route_falls_back_and_keeps_the_raw_grant(tmp_path, monkeypatch):
|
||||
# A host granted recall alone may not read original conversations.
|
||||
assert recall_only["mode"] == "raw" and recall_only["hits"] == []
|
||||
assert len(searched) == 1
|
||||
|
||||
|
||||
def test_the_total_recall_budget_stays_under_the_brokers_wait():
|
||||
from one_memory.recall_fallback import recall_total_timeout_seconds
|
||||
|
||||
assert recall_total_timeout_seconds(800) == pytest.approx(3.8)
|
||||
# The broker gives Core 15 seconds; Core answers before it gives up.
|
||||
assert recall_total_timeout_seconds(60_000) == 12.0
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_a_recall_that_cannot_finish_in_time_is_a_clear_timeout(tmp_path, monkeypatch):
|
||||
"""A recall stuck behind the SDK answers 504 `recall_timeout`, not nothing (#231)."""
|
||||
from one_memory import recall_fallback
|
||||
|
||||
async def stuck(*_args, **_kwargs):
|
||||
await asyncio.Event().wait()
|
||||
|
||||
monkeypatch.setattr(recall_fallback, "recall_with_fallback", stuck)
|
||||
monkeypatch.setattr(recall_fallback, "recall_total_timeout_seconds", lambda _ms: 0.2)
|
||||
|
||||
async def scenario():
|
||||
app = create_app(
|
||||
Settings(
|
||||
data_dir=tmp_path / "data",
|
||||
source_home=tmp_path,
|
||||
api_token=SecretStr("recall-test"),
|
||||
imports_enabled=False,
|
||||
automatic_memory_processing=False,
|
||||
live_sync_enabled=False,
|
||||
),
|
||||
secret_store=InMemorySecretStore(),
|
||||
)
|
||||
async with (
|
||||
app.router.lifespan_context(app),
|
||||
AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": "Bearer recall-test"},
|
||||
) as client,
|
||||
):
|
||||
return await asyncio.wait_for(
|
||||
client.post("/api/v1/recall", json={"query": "数据库"}), 5
|
||||
)
|
||||
|
||||
response = asyncio.run(scenario())
|
||||
|
||||
assert response.status_code == 504
|
||||
assert response.json()["detail"] == "recall_timeout"
|
||||
@@ -42,7 +42,10 @@ RAW_ROWS = {
|
||||
"local": [
|
||||
{
|
||||
"memory_id": "raw-1",
|
||||
"content": "[user]: 我叫费天翔\n[assistant]: 好的,记住了。",
|
||||
"content": (
|
||||
"[user]: 我叫费天翔\n[assistant]: 好的,记住了。"
|
||||
"\n[user]: 我喜欢绿茶\n[assistant]: 也记住了。"
|
||||
),
|
||||
"status": "shadow",
|
||||
"session_id": "s-1",
|
||||
"source": "hermes",
|
||||
@@ -88,6 +91,40 @@ def _chat(user_id: str, memory_id: str, content: str, layer: str, source: str) -
|
||||
}
|
||||
|
||||
|
||||
#: The keyword parameters of the pinned `HyMemoryClient.search`, which takes
|
||||
#: no **kwargs. `test_pinned_sdk_raw_search` checks this set against the
|
||||
#: installed wheel, so the stand-in below rejects what the release rejects.
|
||||
SDK_SEARCH_PARAMETERS = frozenset(
|
||||
{
|
||||
"queries",
|
||||
"scene",
|
||||
"search_mode",
|
||||
"user_ids",
|
||||
"agent_ids",
|
||||
"session_ids",
|
||||
"include_sources",
|
||||
"exclude_sources",
|
||||
"limit",
|
||||
"min_score",
|
||||
"working_fact_min_score",
|
||||
"working_experience_min_score",
|
||||
"schema_min_score",
|
||||
"schema_limit",
|
||||
"intention_limit",
|
||||
"created_after",
|
||||
"reader",
|
||||
"workspace_id",
|
||||
"branch",
|
||||
"request_id",
|
||||
"layers",
|
||||
"statuses",
|
||||
"embed_override",
|
||||
"query_embedding",
|
||||
"rerank",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class RecordingSdk:
|
||||
"""The public client surface, recording every call's keyword arguments."""
|
||||
|
||||
@@ -106,6 +143,10 @@ class RecordingSdk:
|
||||
# The SDK answers rows below a caller's floor in some lanes (a schema
|
||||
# floor, a fused rank score), so Core must filter what it returns.
|
||||
def search(self, query, **kwargs):
|
||||
unknown = set(kwargs) - SDK_SEARCH_PARAMETERS
|
||||
if unknown:
|
||||
# The pinned `HyMemoryClient.search` takes no **kwargs.
|
||||
raise TypeError(f"search() got unexpected keyword arguments {sorted(unknown)}")
|
||||
self._record("search", {"query": query, **kwargs})
|
||||
[user_id] = kwargs["user_ids"]
|
||||
if kwargs.get("layers") == ["l1_raw"]:
|
||||
@@ -271,6 +312,10 @@ async def _model_ready(_app) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def _model_not_ready(_app) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def run_with_sdk(tmp_path, monkeypatch, scenario) -> None:
|
||||
async def main() -> None:
|
||||
app = application(tmp_path)
|
||||
@@ -347,6 +392,75 @@ def test_every_context_search_option_reaches_the_sdk_and_absent_ones_keep_the_ol
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_raw_context_search_can_return_turns_instead_of_storage_batches(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
response = await client.post(
|
||||
"/api/v2/context/search",
|
||||
json={
|
||||
"query": "绿茶",
|
||||
"kinds": ["raw"],
|
||||
"raw_unit": "turn",
|
||||
"limit": 20,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
raw_one = [item for item in response.json()["items"] if item["raw_id"] == "raw-1"]
|
||||
assert len(raw_one) == 1
|
||||
assert raw_one[0]["turn_index"] == 1
|
||||
assert "我喜欢绿茶" in raw_one[0]["content"]
|
||||
assert "我叫费天翔" not in raw_one[0]["content"]
|
||||
# pro/ultra: a shadow raw is already in its extracted memories.
|
||||
assert "statuses" not in sdk.last("search")
|
||||
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_lite_raw_search_is_plain_rag_over_active_and_shadow_raws(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
app.state.memory_mode = lambda: "lite"
|
||||
response = await client.post(
|
||||
"/api/v2/context/search",
|
||||
json={"query": "喜欢喝绿茶的人", "kinds": ["raw"], "raw_unit": "turn", "limit": 5},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
sent = sdk.last("search")
|
||||
assert sent["statuses"] == ["active", "shadow"] and sent["limit"] == 5
|
||||
items = response.json()["items"]
|
||||
# No turn contains the query, yet every vector hit is returned, in
|
||||
# score order: raw-1 by its closest turn, raw-2 by its only one.
|
||||
assert [(item["raw_id"], item["turn_index"]) for item in items] == [
|
||||
("raw-1", 1),
|
||||
("raw-2", 0),
|
||||
]
|
||||
assert [item["score"] for item in items] == [0.97, 0.3]
|
||||
assert "我喜欢绿茶" in items[0]["content"]
|
||||
|
||||
floored = await client.post(
|
||||
"/api/v2/context/search",
|
||||
json={
|
||||
"query": "喜欢喝绿茶的人",
|
||||
"kinds": ["raw"],
|
||||
"raw_unit": "turn",
|
||||
"min_score": 0.5,
|
||||
},
|
||||
)
|
||||
assert [item["raw_id"] for item in floored.json()["items"]] == ["raw-1"]
|
||||
|
||||
# Lite has no model, so recall answers from the raw layer.
|
||||
monkeypatch.setattr("one_memory.api._recall_model_ready", _model_not_ready)
|
||||
recall = await client.post("/api/v1/recall", json={"query": "喜欢喝绿茶的人"})
|
||||
assert recall.status_code == 200, recall.text
|
||||
assert recall.json()["mode"] == "raw"
|
||||
assert sdk.last("search")["statuses"] == ["active", "shadow"]
|
||||
assert [hit["memory_id"] for hit in recall.json()["hits"]][:1] == ["raw-1"]
|
||||
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_every_recall_and_ask_option_reaches_the_sdk(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
@@ -574,7 +688,12 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
|
||||
assert sent["layers"] == ["l1_raw"] and sent["statuses"] == ["active", "shadow"]
|
||||
first = next(item for item in body["items"] if item["raw_id"] == "raw-1")
|
||||
assert first["status"] == "shadow"
|
||||
assert [message["role"] for message in first["messages"]] == ["user", "assistant"]
|
||||
assert [message["role"] for message in first["messages"]] == [
|
||||
"user",
|
||||
"assistant",
|
||||
"user",
|
||||
"assistant",
|
||||
]
|
||||
assert first["messages"][0]["content"] == "我叫费天翔"
|
||||
assert first["role"] is None
|
||||
expected = hashlib.sha256(first["content"].encode()).hexdigest()
|
||||
@@ -584,6 +703,54 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
|
||||
second = next(item for item in body["items"] if item["raw_id"] == "raw-2")
|
||||
assert second["role"] == "user"
|
||||
|
||||
turns = (await client.get("/api/v2/raw", params={"unit": "turn"})).json()
|
||||
assert turns["unit"] == "turn" and turns["total"] == 3
|
||||
first_turns = [item for item in turns["items"] if item["raw_id"] == "raw-1"]
|
||||
assert [item["turn_index"] for item in first_turns] == [1, 0]
|
||||
assert len({item["turn_id"] for item in first_turns}) == 2
|
||||
assert [[message["content"] for message in item["messages"]] for item in first_turns] == [
|
||||
["我喜欢绿茶", "也记住了。"],
|
||||
["我叫费天翔", "好的,记住了。"],
|
||||
]
|
||||
calls_before_refresh = len(sdk.all("list_memories"))
|
||||
await client.get("/api/v2/raw", params={"unit": "turn"})
|
||||
assert len(sdk.all("list_memories")) == calls_before_refresh + 1
|
||||
|
||||
# A live session puts a new raw on top: one listing call, not a rescan.
|
||||
newest = {
|
||||
**RAW_ROWS["local"][1],
|
||||
"memory_id": "raw-new",
|
||||
"content": "[user]: 刚刚说的",
|
||||
"status": "active",
|
||||
}
|
||||
original = RAW_ROWS["local"]
|
||||
monkeypatch.setitem(RAW_ROWS, "local", [newest, *original])
|
||||
calls_before_append = len(sdk.all("list_memories"))
|
||||
appended = (await client.get("/api/v2/raw", params={"unit": "turn"})).json()
|
||||
assert len(sdk.all("list_memories")) == calls_before_append + 1
|
||||
assert appended["total"] == 4
|
||||
assert [item["raw_id"] for item in appended["items"]][:1] == ["raw-new"]
|
||||
assert [item["turn_id"] for item in appended["items"][1:]] == [
|
||||
item["turn_id"] for item in turns["items"]
|
||||
]
|
||||
# Anything else (here a raw going away) lists the raw layer again.
|
||||
monkeypatch.setitem(RAW_ROWS, "local", [newest, original[1]])
|
||||
calls_before_delete = len(sdk.all("list_memories"))
|
||||
shrunk = (await client.get("/api/v2/raw", params={"unit": "turn"})).json()
|
||||
assert len(sdk.all("list_memories")) > calls_before_delete + 1
|
||||
assert shrunk["total"] == 2
|
||||
monkeypatch.setitem(RAW_ROWS, "local", original)
|
||||
calls_before_restore = len(sdk.all("list_memories"))
|
||||
restored = (await client.get("/api/v2/raw", params={"unit": "turn"})).json()
|
||||
assert len(sdk.all("list_memories")) > calls_before_restore + 1
|
||||
assert restored["total"] == 3
|
||||
|
||||
ascending = (
|
||||
await client.get("/api/v2/raw", params={"unit": "turn", "order": "asc"})
|
||||
).json()
|
||||
ascending_first = [item for item in ascending["items"] if item["raw_id"] == "raw-1"]
|
||||
assert [item["turn_index"] for item in ascending_first] == [0, 1]
|
||||
|
||||
await client.get(
|
||||
"/api/v2/raw",
|
||||
params=[("source", "hermes,codex"), ("session_id", "s-1"), ("order", "asc")],
|
||||
|
||||
@@ -1124,13 +1124,13 @@ def test_pre_upgrade_unknown_writes_are_settled_at_cutover_not_retried(store):
|
||||
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.recover_unknown() == 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."""
|
||||
def test_recovering_unknown_writes_works_after_the_legacy_tables_are_dropped(store):
|
||||
"""The old 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
|
||||
|
||||
@@ -1162,14 +1162,10 @@ def test_retrying_unknown_writes_works_after_the_legacy_tables_are_dropped(store
|
||||
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.recover_unknown() == 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
|
||||
with main_database(store.database_path).write() as database, database:
|
||||
database.execute("UPDATE memory_write_jobs SET retries=0 WHERE job_id=?", (identifier,))
|
||||
if sdk_authority:
|
||||
# The SDK-authority queue never brings a dropped table back.
|
||||
assert legacy_tables_present(store.database_path) == ()
|
||||
@@ -1617,7 +1613,7 @@ def test_a_completed_sdk_write_does_not_touch_retired_evidence(store) -> None:
|
||||
class Supervisor:
|
||||
async def process_sdk_job(self, records, *, request_id, before_hy_invocation):
|
||||
del records, request_id
|
||||
await before_hy_invocation()
|
||||
await before_hy_invocation(True)
|
||||
return SimpleNamespace(raw_ids=("raw-1",), memory_ids=("m-1",))
|
||||
|
||||
asyncio.run(
|
||||
|
||||
@@ -91,7 +91,7 @@ def test_bundle_contains_management_sdk_config_and_verified_manifest(tmp_path):
|
||||
assert manifest["bundle_id"] == "migration-1"
|
||||
assert manifest["sdk_namespace"] == "governed-v2"
|
||||
assert manifest["runtime_build"] == "build-13"
|
||||
assert manifest["snapshot_sdk_version"] == "2.0.12"
|
||||
assert manifest["snapshot_sdk_version"] == "2.0.14"
|
||||
paths = {item["path"]: item for item in manifest["files"]}
|
||||
assert {
|
||||
"management/one-memory.sqlite3",
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"""The sedimentation API persists decisions and resumes the SDK queue."""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
"""The sedimentation API persists decisions without rewriting parked work."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from one_memory.api import create_app
|
||||
from one_memory.config import Settings
|
||||
from one_memory.memory_server.contracts import BatchIdentity, WriteRequest
|
||||
from one_memory.memory_server.durable_queue import WAITING_FOR_PERMISSION
|
||||
|
||||
|
||||
def _app(tmp_path):
|
||||
@@ -25,51 +24,62 @@ def _app(tmp_path):
|
||||
)
|
||||
|
||||
|
||||
def test_sedimentation_route_persists_and_resumes_without_restart(tmp_path) -> None:
|
||||
def test_sedimentation_route_persists_without_restart(tmp_path) -> None:
|
||||
with TestClient(_app(tmp_path), headers={"Authorization": "Bearer test"}) as client:
|
||||
assert client.get("/api/v1/sedimentation").json()["enabled"] is True
|
||||
assert client.put(
|
||||
"/api/v1/sedimentation", json={"enabled": False}
|
||||
).json()["enabled"] is False
|
||||
assert (
|
||||
client.put("/api/v1/sedimentation", json={"enabled": False}).json()["enabled"] is False
|
||||
)
|
||||
assert client.get("/api/v1/sedimentation").json()["enabled"] is False
|
||||
assert client.put(
|
||||
"/api/v1/sedimentation", json={"enabled": True}
|
||||
).json()["enabled"] is True
|
||||
assert client.put("/api/v1/sedimentation", json={"enabled": True}).json()["enabled"] is True
|
||||
|
||||
|
||||
def test_switch_answers_before_sdk_queue_resume_finishes(tmp_path, monkeypatch) -> None:
|
||||
def test_switch_does_not_run_the_broad_parked_queue_resume(tmp_path, monkeypatch) -> None:
|
||||
app = _app(tmp_path)
|
||||
released = threading.Event()
|
||||
finished = threading.Event()
|
||||
called = False
|
||||
|
||||
with TestClient(app, headers={"Authorization": "Bearer test"}) as client:
|
||||
async def slow_resume() -> None:
|
||||
await asyncio.get_running_loop().run_in_executor(None, released.wait, 10.0)
|
||||
finished.set()
|
||||
|
||||
monkeypatch.setattr(app.state.sdk_memory_runtime, "resume_allowed", slow_resume)
|
||||
assert client.put(
|
||||
"/api/v1/sedimentation", json={"enabled": False}
|
||||
).status_code == 200
|
||||
async def forbidden_resume() -> None:
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
monkeypatch.setattr(app.state.sdk_memory_runtime, "resume_allowed", forbidden_resume)
|
||||
assert client.put("/api/v1/sedimentation", json={"enabled": False}).status_code == 200
|
||||
resumed = client.put("/api/v1/sedimentation", json={"enabled": True})
|
||||
|
||||
assert resumed.status_code == 200
|
||||
assert resumed.json()["enabled"] is True
|
||||
assert finished.is_set() is False
|
||||
released.set()
|
||||
assert called is False
|
||||
|
||||
|
||||
def test_a_failing_sdk_queue_resume_does_not_fail_the_switch(tmp_path, monkeypatch) -> None:
|
||||
def test_switch_does_not_rewrite_permission_parked_jobs(tmp_path) -> None:
|
||||
app = _app(tmp_path)
|
||||
with TestClient(app, headers={"Authorization": "Bearer test"}) as client:
|
||||
async def broken_resume() -> None:
|
||||
raise RuntimeError("sdk_queue_unavailable")
|
||||
queue = app.state.memory_write_queue
|
||||
queue.evidence = None
|
||||
request = 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=["e-1"],
|
||||
idempotency_key="permission-parked",
|
||||
)
|
||||
record = queue.submit(request)
|
||||
queue.settle(
|
||||
record.job_id,
|
||||
state=WAITING_FOR_PERMISSION,
|
||||
error_code="model_content_permission_required",
|
||||
hy_invoked=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(app.state.sdk_memory_runtime, "resume_allowed", broken_resume)
|
||||
assert client.put(
|
||||
"/api/v1/sedimentation", json={"enabled": False}
|
||||
).status_code == 200
|
||||
resumed = client.put("/api/v1/sedimentation", json={"enabled": True})
|
||||
assert client.put("/api/v1/sedimentation", json={"enabled": False}).status_code == 200
|
||||
assert client.put("/api/v1/sedimentation", json={"enabled": True}).status_code == 200
|
||||
|
||||
assert resumed.status_code == 200
|
||||
assert resumed.json()["enabled"] is True
|
||||
parked = queue.get("permission-parked")
|
||||
assert parked.state == WAITING_FOR_PERMISSION
|
||||
assert parked.detail == "model_content_permission_required"
|
||||
@@ -16,7 +16,7 @@ from one_memory.imports.connections import SourceConnectionStore
|
||||
from one_memory.imports.grants import SourceGrantStore
|
||||
from one_memory.imports.jobs import ImportJobStore
|
||||
from one_memory.imports.sources.registry import build_source_catalog
|
||||
from one_memory.onboarding import OnboardingStore
|
||||
from one_memory.onboarding import OnboardingProgressUpdate, OnboardingStore
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entry", ["connect", "authorize"])
|
||||
@@ -371,7 +371,14 @@ def test_onboarding_persists_one_history_window_on_every_accepted_source(tmp_pat
|
||||
database = tmp_path / "data.sqlite"
|
||||
grants = SourceGrantStore(database, home=home)
|
||||
jobs = ImportJobStore(database)
|
||||
OnboardingStore(database)
|
||||
onboarding = OnboardingStore(database)
|
||||
onboarding.update_progress(
|
||||
OnboardingProgressUpdate(
|
||||
mode_intent="pro",
|
||||
setup_step="model",
|
||||
model_profile_id="9be6d465-4cf2-4c72-93f7-15f3c012bc80",
|
||||
)
|
||||
)
|
||||
store = SourceConnectionStore(grants, jobs, home=home)
|
||||
catalog = build_source_catalog(home=home, environment={})
|
||||
cutoff = datetime(2026, 10, 13, 5, 0, tzinfo=UTC)
|
||||
@@ -391,3 +398,7 @@ def test_onboarding_persists_one_history_window_on_every_accepted_source(tmp_pat
|
||||
assert receipt.accepted_source_ids == ("claude-code", "codex")
|
||||
assert {job.history_after for job in receipt.jobs} == {cutoff}
|
||||
assert store.get("bounded-first-import") == receipt
|
||||
state = onboarding.get()
|
||||
assert state.mode_intent == "pro"
|
||||
assert state.setup_step == "model"
|
||||
assert state.model_profile_id == "9be6d465-4cf2-4c72-93f7-15f3c012bc80"
|
||||
Generated
+4
-4
@@ -991,7 +991,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "hy-memory"
|
||||
version = "2.0.12"
|
||||
version = "2.0.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "chromadb" },
|
||||
@@ -1008,9 +1008,9 @@ dependencies = [
|
||||
{ name = "scikit-learn" },
|
||||
{ name = "tiktoken" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/44/19/bcd0de44ae2011ee1ad70579996aad72e18f0ca292df22d1545db05db850/hy_memory-2.0.12.tar.gz", hash = "sha256:c1231596b4cf12595775541f81680d4819461cb69e4aa4dc449f876d4c3b6742", size = 1246969, upload-time = "2026-11-02T06:30:37.448Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4a/68/932ac0f684a971741643d8dab0a209c6a09d5a132ca607bbb9355e0b8cf2/hy_memory-2.0.14.tar.gz", hash = "sha256:94cf354f51ab8b506c0ac4909a95a6b13fd542f81c894322210beb20996f16fd", size = 1253180, upload-time = "2026-11-04T09:52:17.127Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/75/9e/b64c7643c1bad1ddca4623a39bb62423bed0c22e7fd0d86be1ff73b2a17a/hy_memory-2.0.12-py3-none-any.whl", hash = "sha256:7a2332288197fafb89d5037eac7fef97df4c80794ead18159d21da84cde431df", size = 1316922, upload-time = "2026-11-02T06:30:35.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/7f/9ecbaa2589dae9bd83657adc6388b0c7b0be415c5584fef045db309911eb/hy_memory-2.0.14-py3-none-any.whl", hash = "sha256:6df76d8dc84aaa352149e85055331d22ef904e4acd4540a2328bf058a3f0f2a6", size = 1322364, upload-time = "2026-11-04T09:52:14.096Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1837,7 +1837,7 @@ requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.141.1" },
|
||||
{ name = "filelock", specifier = ">=3.32.4" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "hy-memory", specifier = "==2.0.12" },
|
||||
{ name = "hy-memory", specifier = "==2.0.14" },
|
||||
{ name = "keyring", specifier = ">=25.7.0" },
|
||||
{ name = "numpy", specifier = "==2.5.2" },
|
||||
{ name = "onnxruntime", specifier = "==1.23.2" },
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { App } from "./App";
|
||||
import {
|
||||
commitConfirmedMemoryMode,
|
||||
issueConfirmedModeTicket,
|
||||
resetConfirmedMemoryModeForTests,
|
||||
} from "./confirmed-memory-mode";
|
||||
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
|
||||
|
||||
it("localizes shared navigation and footer in Spanish", async () => {
|
||||
@@ -36,6 +41,7 @@ it("localizes shared navigation and footer in Spanish", async () => {
|
||||
});
|
||||
|
||||
const memoryProps = vi.hoisted(() => vi.fn());
|
||||
const progressProps = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./api", () => ({
|
||||
getOnboarding: vi.fn().mockResolvedValue({ status: "completed" }),
|
||||
@@ -62,11 +68,21 @@ vi.mock("./MemorySpace", () => ({
|
||||
vi.mock("./ModelSettings", () => ({
|
||||
ModelSettings: () => <p>模型设置内容</p>,
|
||||
}));
|
||||
vi.mock("./ImportProgressStrip", () => ({ ImportProgressStrip: () => null }));
|
||||
vi.mock("./R6Settings", () => ({
|
||||
R6Settings: () => <p>设置视图内容</p>,
|
||||
}));
|
||||
vi.mock("./ImportProgressStrip", () => ({
|
||||
ImportProgressStrip: (props: unknown) => {
|
||||
progressProps(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
vi.mock("./HealthPanel", () => ({ HealthPanel: () => null }));
|
||||
vi.mock("./OnboardingFlow", () => ({ OnboardingFlow: () => null }));
|
||||
|
||||
beforeEach(() => {
|
||||
resetConfirmedMemoryModeForTests();
|
||||
progressProps.mockClear();
|
||||
window.history.replaceState({}, "", "/");
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -105,6 +121,23 @@ it("uses explicit memory deep links and restores the applications default URL",
|
||||
).toBe("page");
|
||||
});
|
||||
|
||||
it("passes confirmed mode capabilities to the production progress strip", async () => {
|
||||
commitConfirmedMemoryMode("lite", issueConfirmedModeTicket("settings"));
|
||||
render(<App />);
|
||||
const nav = await screen.findByRole("navigation", { name: "主要导航" });
|
||||
await userEvent.click(within(nav).getByRole("button", { name: "设置" }));
|
||||
await waitFor(() =>
|
||||
expect(progressProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modeCapabilities: expect.objectContaining({
|
||||
content_plane: "raw",
|
||||
generation_required: false,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an independent scroll position for each primary page", async () => {
|
||||
render(<App />);
|
||||
const nav = await screen.findByRole("navigation", { name: "主要导航" });
|
||||
|
||||
@@ -37,7 +37,41 @@ const { getOnboarding, sources, modelProps, mutations } = vi.hoisted(() => ({
|
||||
vi.mock("./api", () => ({
|
||||
getOnboarding,
|
||||
getModelProfiles: vi.fn().mockResolvedValue([]),
|
||||
getMemoryMode: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: false,
|
||||
forced_by_env: false,
|
||||
modes: ["lite", "pro", "ultra"],
|
||||
graph_build: {
|
||||
state: "idle",
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
}),
|
||||
getEvolveSchedule: vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
time: "03:00",
|
||||
}),
|
||||
getEvolveOverview: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [],
|
||||
}),
|
||||
getMemoryProcessing: vi.fn().mockResolvedValue({
|
||||
embedding_switch: null,
|
||||
}),
|
||||
setMemoryMode: vi.fn(),
|
||||
setEvolveSchedule: vi.fn(),
|
||||
startEvolve: vi.fn(),
|
||||
apiErrorCode: vi.fn().mockReturnValue(null),
|
||||
getSources: vi.fn().mockResolvedValue(sources),
|
||||
getCurrentEmbeddingSwitch: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("unsupported")),
|
||||
embeddingSwitchUnsupported: vi.fn().mockReturnValue(true),
|
||||
...mutations,
|
||||
}));
|
||||
vi.mock("./use-import-sources", () => ({
|
||||
@@ -119,7 +153,9 @@ it("enters D47 settings without a tour or mandatory second scope-confirmation fl
|
||||
).toBeNull();
|
||||
noModelWrites();
|
||||
expect(screen.queryByRole("button", { name: "管理已有授权" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "模型" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "界面语言" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "记忆模式" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "模型配置" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "连接其他应用" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "帮助与诊断" })).toBeTruthy();
|
||||
noModelWrites();
|
||||
@@ -182,13 +218,14 @@ it("keeps an open model draft while switching settings groups", async () => {
|
||||
window.history.replaceState({}, "", "/?view=settings");
|
||||
render(<App />);
|
||||
await waitFor(() => expect(modelProps).toHaveBeenCalled());
|
||||
await userEvent.click(screen.getByRole("button", { name: "模型配置" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "配置模型" }));
|
||||
await userEvent.type(
|
||||
screen.getByLabelText("保留的模型草稿"),
|
||||
"unsaved draft",
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "连接其他应用" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "模型" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "模型配置" }));
|
||||
expect(window.location.search).toBe("?view=settings");
|
||||
expect(screen.getByLabelText("保留的模型草稿")).toHaveProperty(
|
||||
"value",
|
||||
|
||||
@@ -23,8 +23,10 @@ import {
|
||||
import { getOnboarding } from "./api";
|
||||
import { useApplicationsLanguage } from "./applications-language";
|
||||
import { CallHistory } from "./CallHistory";
|
||||
import { useConfirmedMemoryMode } from "./confirmed-memory-mode";
|
||||
import { DebugLogPanel } from "./DebugLogPanel";
|
||||
import { DesktopFrame } from "./DesktopFrame";
|
||||
import { EmbeddingSwitchGlobalStatus } from "./EmbeddingSwitchExperience";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
import { HealthPanel } from "./HealthPanel";
|
||||
import { ImportProgressStrip } from "./ImportProgressStrip";
|
||||
@@ -34,6 +36,7 @@ import { OnboardingFlow } from "./OnboardingFlow";
|
||||
import { R6Button, R6LocalStorage } from "./R6Controls";
|
||||
import { R6Settings } from "./R6Settings";
|
||||
import { prefetchDataRuntime } from "./runtime-state";
|
||||
import { EmbeddingSwitchProvider } from "./use-embedding-switch";
|
||||
import "./styles.css";
|
||||
import "./core-visual.css";
|
||||
import "./r6-shell.css";
|
||||
@@ -47,16 +50,19 @@ const rendererStartedAt = Date.now();
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<DesktopFrame>
|
||||
<AppContent />
|
||||
<DebugLogPanel />
|
||||
</DesktopFrame>
|
||||
<EmbeddingSwitchProvider>
|
||||
<DesktopFrame>
|
||||
<AppContent />
|
||||
<DebugLogPanel />
|
||||
</DesktopFrame>
|
||||
</EmbeddingSwitchProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const { t } = useGuiLanguage();
|
||||
const { a } = useApplicationsLanguage();
|
||||
const confirmedMode = useConfirmedMemoryMode();
|
||||
const [onboarding, setOnboarding] = useState<OnboardingState | null>(null);
|
||||
const [startupRead, setStartupRead] = useState<"loading" | "ready" | "error">(
|
||||
"loading",
|
||||
@@ -309,6 +315,10 @@ function AppContent() {
|
||||
ref={content}
|
||||
data-view={displayedView}
|
||||
>
|
||||
<EmbeddingSwitchGlobalStatus
|
||||
onConfigureSearch={() => navigate("settings")}
|
||||
onDiagnostics={() => navigate("health")}
|
||||
/>
|
||||
{/* First run hands the user in here while the import is still going.
|
||||
* Without this the work would be invisible from every screen except
|
||||
* the one they were no longer required to sit on. */}
|
||||
@@ -322,6 +332,9 @@ function AppContent() {
|
||||
sourceSnapshot={imports.sourceSnapshot}
|
||||
onRefresh={imports.refresh}
|
||||
arrival={memoryArrival}
|
||||
modeCapabilities={
|
||||
confirmedMode.capabilities ?? memoryArrival?.capabilities
|
||||
}
|
||||
onConfigureModel={() => navigate("settings")}
|
||||
onJobAction={imports.changeJob}
|
||||
showSourceLink={displayedView !== "settings"}
|
||||
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
CALL_HISTORY_ENTRY_VISIBLE,
|
||||
} from "./ApplicationsHome";
|
||||
import * as api from "./api";
|
||||
import {
|
||||
commitConfirmedMemoryMode,
|
||||
issueConfirmedModeTicket,
|
||||
resetConfirmedMemoryModeForTests,
|
||||
} from "./confirmed-memory-mode";
|
||||
import { saveConnectionPreferences } from "./connection-preferences";
|
||||
import { resetConnectionReconcile } from "./connection-reconcile";
|
||||
import { LanguageProvider } from "./gui-language";
|
||||
@@ -67,21 +72,37 @@ it("localizes the whole home live without repeating reads or changing imported t
|
||||
expect(summary).toHaveBeenCalledTimes(calls);
|
||||
});
|
||||
|
||||
const { summary, processing, sedimentation, history, liveSync } = vi.hoisted(
|
||||
() => ({
|
||||
const { summary, projection, processing, sedimentation, history, liveSync } =
|
||||
vi.hoisted(() => ({
|
||||
summary: vi.fn(),
|
||||
projection: vi.fn(),
|
||||
processing: vi.fn(),
|
||||
sedimentation: vi.fn(),
|
||||
history: vi.fn(),
|
||||
liveSync: vi.fn(),
|
||||
}),
|
||||
);
|
||||
}));
|
||||
vi.mock("./call-history-client", async (original) => ({
|
||||
...(await original<typeof import("./call-history-client")>()),
|
||||
getCallHistory: history,
|
||||
}));
|
||||
vi.mock("./api", () => ({
|
||||
getHomeProjection: projection,
|
||||
getHomeSummary: summary,
|
||||
homeProjectionUnsupported: vi.fn().mockReturnValue(true),
|
||||
getMemoryMode: vi.fn().mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: true,
|
||||
forced_by_env: false,
|
||||
modes: ["lite", "pro", "ultra"],
|
||||
graph_build: {
|
||||
state: "idle",
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
}),
|
||||
getSdkRaw: vi.fn(),
|
||||
getLiveSyncHealth: liveSync,
|
||||
getMemoryProcessing: processing,
|
||||
getSedimentation: sedimentation,
|
||||
@@ -106,6 +127,22 @@ const imports = {
|
||||
sourceSnapshot: { stale: false },
|
||||
} as unknown as Parameters<typeof ApplicationsHome>[0]["imports"];
|
||||
beforeEach(() => {
|
||||
resetConfirmedMemoryModeForTests();
|
||||
vi.mocked(api.getMemoryMode).mockResolvedValue({
|
||||
mode: "pro",
|
||||
configured_mode: "pro",
|
||||
explicitly_configured: true,
|
||||
forced_by_env: false,
|
||||
modes: ["lite", "pro", "ultra"],
|
||||
graph_build: {
|
||||
state: "idle",
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
});
|
||||
vi.mocked(api.getSdkRaw).mockReset();
|
||||
vi.mocked(api.homeProjectionUnsupported).mockReturnValue(true);
|
||||
history
|
||||
.mockReset()
|
||||
.mockResolvedValue({ available: true, recording_error: false, items: [] });
|
||||
@@ -122,6 +159,7 @@ beforeEach(() => {
|
||||
},
|
||||
observed_at: "2026-09-19T00:00:00Z",
|
||||
});
|
||||
projection.mockReset().mockRejectedValue(new Error("legacy core"));
|
||||
processing.mockReset().mockResolvedValue({ running: 0, pending: 0 });
|
||||
sedimentation.mockReset().mockResolvedValue({
|
||||
enabled: true,
|
||||
@@ -149,6 +187,218 @@ beforeEach(() => {
|
||||
]),
|
||||
} as unknown as NonNullable<typeof window.oneMemory>;
|
||||
});
|
||||
it("hides an old projection immediately when confirmed mode changes", async () => {
|
||||
vi.mocked(api.homeProjectionUnsupported).mockReturnValue(false);
|
||||
commitConfirmedMemoryMode("pro", issueConfirmedModeTicket("settings"));
|
||||
projection
|
||||
.mockResolvedValueOnce({
|
||||
projection_version: 1,
|
||||
user_id: "local",
|
||||
effective_mode: "pro",
|
||||
capabilities: {
|
||||
content_plane: "memory",
|
||||
generation_required: true,
|
||||
graph_enabled: false,
|
||||
evolve_available: false,
|
||||
},
|
||||
visible_count: 1,
|
||||
display_state: "excerpt",
|
||||
latest: {
|
||||
type: "memory",
|
||||
memory_id: "old",
|
||||
scene: "chat",
|
||||
kind: "chat",
|
||||
variant: null,
|
||||
source_id: "app-0",
|
||||
excerpt: "旧模式投影",
|
||||
truncated: false,
|
||||
time: null,
|
||||
time_basis: "update",
|
||||
},
|
||||
observed_at: "2026-11-04T00:00:00Z",
|
||||
})
|
||||
.mockImplementationOnce(() => new Promise(() => {}));
|
||||
render(
|
||||
<ApplicationsHome
|
||||
imports={imports}
|
||||
onManage={() => {}}
|
||||
onMemories={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText("旧模式投影")).toBeTruthy();
|
||||
act(() =>
|
||||
commitConfirmedMemoryMode("lite", issueConfirmedModeTicket("settings")),
|
||||
);
|
||||
expect(screen.queryByText("旧模式投影")).toBeNull();
|
||||
expect(screen.getAllByText("正在核对本机原始对话…")).toHaveLength(2);
|
||||
});
|
||||
it("renders the lite projection as raw data with mode metadata and no navigation tail", async () => {
|
||||
vi.mocked(api.homeProjectionUnsupported).mockReturnValue(false);
|
||||
projection.mockResolvedValue({
|
||||
projection_version: 1,
|
||||
user_id: "local",
|
||||
effective_mode: "lite",
|
||||
capabilities: {
|
||||
content_plane: "raw",
|
||||
generation_required: false,
|
||||
graph_enabled: false,
|
||||
evolve_available: false,
|
||||
},
|
||||
visible_count: 7,
|
||||
display_state: "excerpt",
|
||||
latest: {
|
||||
type: "raw",
|
||||
raw_id: "raw-1",
|
||||
session_id: "session-1",
|
||||
source_id: "app-0",
|
||||
excerpt: "用户原始对话摘要",
|
||||
truncated: false,
|
||||
display_state: "excerpt",
|
||||
time: "2026-09-19T00:00:00Z",
|
||||
time_basis: "conversation",
|
||||
},
|
||||
observed_at: "2026-09-19T00:00:01Z",
|
||||
});
|
||||
render(
|
||||
<ApplicationsHome
|
||||
imports={imports}
|
||||
onManage={() => {}}
|
||||
onMemories={vi.fn()}
|
||||
onCalls={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText("原始对话")).toBeTruthy();
|
||||
expect(screen.getByText("7")).toBeTruthy();
|
||||
expect(screen.getByText("用户原始对话摘要")).toBeTruthy();
|
||||
expect(screen.getByText(/原始对话 · App 0 · 对话时间/)).toBeTruthy();
|
||||
expect(screen.getByText("lite")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "查看记忆" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "查看记录" })).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["conversation", "对话时间"],
|
||||
["import", "导入时间"],
|
||||
["write", "写入时间"],
|
||||
["update", "更新时间"],
|
||||
[null, null],
|
||||
] as const)(
|
||||
"renders projection time basis %s truthfully",
|
||||
async (basis, label) => {
|
||||
vi.mocked(api.homeProjectionUnsupported).mockReturnValue(false);
|
||||
projection.mockResolvedValue({
|
||||
projection_version: 1,
|
||||
user_id: "local",
|
||||
effective_mode: "lite",
|
||||
capabilities: {
|
||||
content_plane: "raw",
|
||||
generation_required: false,
|
||||
graph_enabled: false,
|
||||
evolve_available: false,
|
||||
},
|
||||
visible_count: 1,
|
||||
display_state: "excerpt",
|
||||
latest: {
|
||||
type: "raw",
|
||||
raw_id: "raw-basis",
|
||||
session_id: "session-basis",
|
||||
source_id: "app-0",
|
||||
excerpt: "basis excerpt",
|
||||
truncated: false,
|
||||
display_state: "excerpt",
|
||||
time: "2026-09-19T00:00:00Z",
|
||||
time_basis: basis,
|
||||
},
|
||||
observed_at: "2026-09-19T00:00:01Z",
|
||||
});
|
||||
render(mountHome());
|
||||
const activity = (await screen.findByText("basis excerpt")).closest(
|
||||
"section",
|
||||
);
|
||||
const meta = activity?.querySelector("small")?.textContent ?? "";
|
||||
if (label) expect(meta).toContain(label);
|
||||
else expect(meta).not.toMatch(/对话时间|导入时间|写入时间|更新时间/);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not publish a stopped legacy raw fallback", async () => {
|
||||
vi.mocked(api.getMemoryMode).mockResolvedValue({
|
||||
mode: "lite",
|
||||
configured_mode: "lite",
|
||||
explicitly_configured: true,
|
||||
forced_by_env: false,
|
||||
modes: ["lite", "pro", "ultra"],
|
||||
graph_build: {
|
||||
state: "idle",
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
});
|
||||
let resolveRaw!: (value: {
|
||||
user_id: string;
|
||||
items: [];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}) => void;
|
||||
vi.mocked(api.getSdkRaw).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveRaw = resolve;
|
||||
}),
|
||||
);
|
||||
const view = render(mountHome(true));
|
||||
await waitFor(() => expect(api.getSdkRaw).toHaveBeenCalledWith({ limit: 1 }));
|
||||
view.rerender(mountHome(false));
|
||||
await act(async () =>
|
||||
resolveRaw({
|
||||
user_id: "local",
|
||||
items: [],
|
||||
total: 73,
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByText("73")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses only total from the legacy raw fallback", async () => {
|
||||
vi.mocked(api.getMemoryMode).mockResolvedValue({
|
||||
mode: "lite",
|
||||
configured_mode: "lite",
|
||||
explicitly_configured: true,
|
||||
forced_by_env: false,
|
||||
modes: ["lite", "pro", "ultra"],
|
||||
graph_build: {
|
||||
state: "idle",
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
});
|
||||
vi.mocked(api.getSdkRaw).mockResolvedValue({
|
||||
user_id: "local",
|
||||
items: [
|
||||
{
|
||||
raw_id: "raw-private",
|
||||
content: "正文不应进入首页",
|
||||
user_id: "local",
|
||||
agent_id: "agent",
|
||||
session_id: "session",
|
||||
source: "app-0",
|
||||
memory_at: null,
|
||||
gmt_created: null,
|
||||
custom: {},
|
||||
},
|
||||
],
|
||||
total: 9,
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
});
|
||||
render(mountHome());
|
||||
expect(await screen.findByText("9")).toBeTruthy();
|
||||
expect(screen.queryByText("正文不应进入首页")).toBeNull();
|
||||
});
|
||||
it.each([
|
||||
[true, "Organizando", "Organizando nuevas conversaciones"],
|
||||
[false, "En pausa", "Se dejó de generar memorias nuevas"],
|
||||
@@ -197,7 +447,7 @@ it("localizes startup recovery and empty states without leaking errors", async (
|
||||
"El servicio se está iniciando; se actualizará automáticamente",
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("0")).toBeTruthy();
|
||||
expect(screen.getByText("—")).toBeTruthy();
|
||||
expect(screen.getByText("Aún no hay aplicaciones para mostrar")).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Reintentar lectura" }),
|
||||
@@ -235,7 +485,7 @@ it("shows latest recorded call separately without exposing recent-update detail
|
||||
);
|
||||
await screen.findByText("实际治理记忆");
|
||||
fireEvent.click(screen.getByRole("tab", { name: "最近调用" }));
|
||||
await screen.findByText("Codex 查到 3 条相关内容");
|
||||
await screen.findByText("Codex 找到 2 条记忆 · 1 条原始会话");
|
||||
expect(document.querySelector(".r6-home-call-direction")).toBeTruthy();
|
||||
expect(screen.queryByText("实际治理记忆")).toBeNull();
|
||||
expect(screen.getByText(/Codex · 今天 .+ · 查询成功/)).toBeTruthy();
|
||||
@@ -292,7 +542,7 @@ it("skips direct diagnostics and shows the latest attributed application call",
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "最近调用" }));
|
||||
expect(await screen.findByText("WorkBuddy 查到 5 条相关内容")).toBeTruthy();
|
||||
expect(await screen.findByText("WorkBuddy 找到 5 条记忆")).toBeTruthy();
|
||||
expect(screen.queryByText(/直接调用/)).toBeNull();
|
||||
expect(screen.getByText(/WorkBuddy · 今天 .+ · 查询成功/)).toBeTruthy();
|
||||
const direction = document.querySelector(".r6-home-call-direction");
|
||||
@@ -330,7 +580,7 @@ it("shows only the host for memory hits whose references have no source", async
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "最近调用" }));
|
||||
await screen.findByText("WorkBuddy 查到 2 条相关内容");
|
||||
await screen.findByText("WorkBuddy 找到 2 条记忆");
|
||||
const direction = document.querySelector(".r6-home-call-direction");
|
||||
expect(direction?.querySelectorAll("img")).toHaveLength(1);
|
||||
expect(direction?.querySelector("img")?.getAttribute("src")).toContain(
|
||||
@@ -368,7 +618,7 @@ it("keeps source to host direction for raw hits with a source", async () => {
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "最近调用" }));
|
||||
await screen.findByText("WorkBuddy 查到 1 条相关内容");
|
||||
await screen.findByText("WorkBuddy 找到 1 条原始会话");
|
||||
const direction = document.querySelector(".r6-home-call-direction");
|
||||
expect(direction?.querySelectorAll("img")).toHaveLength(1);
|
||||
expect(direction?.querySelector("svg")).toBeTruthy();
|
||||
@@ -416,7 +666,9 @@ it("shows unlinked apps and the latest returned call on matching rows", async ()
|
||||
onCalls={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText(/Cursor 查到 3 条相关内容 ·/)).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByText(/Cursor 找到 2 条记忆 · 1 条原始会话 ·/),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("尚未关联")).toBeTruthy();
|
||||
});
|
||||
it("keeps the latest call visible when recording is incomplete", async () => {
|
||||
@@ -444,7 +696,7 @@ it("keeps the latest call visible when recording is incomplete", async () => {
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "最近调用" }));
|
||||
await screen.findByText("Cursor 查到 1 条相关内容");
|
||||
await screen.findByText("Cursor 找到 1 条记忆");
|
||||
expect(screen.getByText(/有几次调用没被记下来/)).toBeTruthy();
|
||||
expect(screen.queryByText("暂时无法读取调用记录")).toBeNull();
|
||||
expect(screen.queryByText("暂无已记录调用")).toBeNull();
|
||||
@@ -496,7 +748,7 @@ it("marks a retained application activity as last-read when call history cannot
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText(/App 0 查到 3 条相关内容 ·/);
|
||||
await screen.findByText(/App 0 找到 2 条记忆 · 1 条原始会话 ·/);
|
||||
view.rerender(
|
||||
<ApplicationsHome
|
||||
active={false}
|
||||
@@ -520,8 +772,10 @@ it("marks a retained application activity as last-read when call history cannot
|
||||
await waitFor(() => expect(history).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(await screen.findByText("实际治理记忆")).toBeTruthy();
|
||||
expect(screen.getByText(/上次读取:App 0 查到 3 条相关内容/)).toBeTruthy();
|
||||
expect(screen.queryByText(/^App 0 查到/)).toBeNull();
|
||||
expect(
|
||||
screen.getByText(/上次读取:App 0 找到 2 条记忆 · 1 条原始会话/),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText(/^App 0 找到/)).toBeNull();
|
||||
});
|
||||
it("does not say an unavailable call returned memories", async () => {
|
||||
history.mockResolvedValue({
|
||||
@@ -770,7 +1024,7 @@ it("keeps authoritative home and processing data when only the source snapshot b
|
||||
expect(screen.getAllByText("整理中")).toHaveLength(2);
|
||||
expect(screen.queryByText("状态待核对")).toBeNull();
|
||||
});
|
||||
it("keeps a neutral zero while the first home summary read recovers", async () => {
|
||||
it("keeps an unknown dash while the first home read recovers", async () => {
|
||||
summary.mockRejectedValue(new Error("private offline detail"));
|
||||
render(
|
||||
<ApplicationsHome
|
||||
@@ -783,7 +1037,7 @@ it("keeps a neutral zero while the first home summary read recovers", async () =
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("后台正在启动,稍后自动更新")).toBeTruthy(),
|
||||
);
|
||||
expect(screen.getByText("0")).toBeTruthy();
|
||||
expect(screen.getByText("—")).toBeTruthy();
|
||||
expect(screen.getByText("正在准备记忆概览")).toBeTruthy();
|
||||
expect(screen.queryByText(/暂时?无法读取|读取失败/)).toBeNull();
|
||||
expect(document.body.textContent).not.toContain("private offline detail");
|
||||
@@ -944,6 +1198,50 @@ it("shows a paused switch immediately even when task rows still say running", as
|
||||
expect(screen.queryByText("新的对话正在整理")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows lite raw ingestion as running even when model sedimentation is paused", async () => {
|
||||
vi.mocked(api.homeProjectionUnsupported).mockReturnValue(false);
|
||||
projection.mockResolvedValue({
|
||||
projection_version: 1,
|
||||
user_id: "local",
|
||||
effective_mode: "lite",
|
||||
capabilities: {
|
||||
content_plane: "raw",
|
||||
generation_required: false,
|
||||
graph_enabled: false,
|
||||
evolve_available: false,
|
||||
},
|
||||
visible_count: 0,
|
||||
display_state: "empty",
|
||||
latest: null,
|
||||
observed_at: "2026-11-04T00:00:00Z",
|
||||
});
|
||||
processing.mockResolvedValue({
|
||||
running: 0,
|
||||
pending: 10,
|
||||
pipeline: { mode: "lite" },
|
||||
});
|
||||
sedimentation.mockResolvedValue({
|
||||
enabled: false,
|
||||
pending_retained: true,
|
||||
imports_unaffected: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<ApplicationsHome
|
||||
imports={imports}
|
||||
onManage={() => {}}
|
||||
onMemories={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findAllByText("正在保存")).toHaveLength(2);
|
||||
expect(screen.getByText("暂无更新")).toBeTruthy();
|
||||
expect(screen.getByText("已核对,暂无可显示的原始对话更新")).toBeTruthy();
|
||||
expect(screen.getByText("原始对话持续保存,不需要生成模型")).toBeTruthy();
|
||||
expect(screen.queryByText("已暂停")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the import-in-progress empty state stable without a progress link", async () => {
|
||||
summary.mockResolvedValue({
|
||||
memory_count: 0,
|
||||
@@ -1312,7 +1610,7 @@ it("keeps the conversation-sync checking copy before the first home read", () =>
|
||||
expect(screen.queryByText(/个应用已开启/)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a provisional zero while the first summary read is pending", async () => {
|
||||
it("shows an unknown dash while the first projection read is pending", async () => {
|
||||
summary.mockImplementation(() => new Promise(() => {}));
|
||||
history.mockResolvedValue({
|
||||
available: true,
|
||||
@@ -1329,7 +1627,7 @@ it("shows a provisional zero while the first summary read is pending", async ()
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("正在准备记忆概览");
|
||||
expect(screen.getByText("0")).toBeTruthy();
|
||||
expect(screen.getByText("—")).toBeTruthy();
|
||||
expect(screen.getByText("正在核对本机记忆…")).toBeTruthy();
|
||||
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
HomeProjectionV1,
|
||||
HostHomeSummary,
|
||||
LiveSyncHealth,
|
||||
MemoryProcessingSummary,
|
||||
@@ -8,7 +9,15 @@ import { type GuiLocale, isMessageKey } from "@one-memory/i18n";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { getHomeSummary, getLiveSyncHealth, getMemoryProcessing } from "./api";
|
||||
import {
|
||||
getHomeProjection,
|
||||
getHomeSummary,
|
||||
getLiveSyncHealth,
|
||||
getMemoryMode,
|
||||
getMemoryProcessing,
|
||||
getSdkRaw,
|
||||
homeProjectionUnsupported,
|
||||
} from "./api";
|
||||
import { AppMark } from "./app-mark";
|
||||
import {
|
||||
type CallEvent,
|
||||
@@ -17,6 +26,13 @@ import {
|
||||
callRowDetail,
|
||||
getCallHistory,
|
||||
} from "./call-history-client";
|
||||
import {
|
||||
commitConfirmedMemoryMode,
|
||||
confirmedMemoryModeSnapshot,
|
||||
isConfirmedModeTicketCurrent,
|
||||
issueConfirmedModeTicket,
|
||||
useConfirmedMemoryMode,
|
||||
} from "./confirmed-memory-mode";
|
||||
import { hostMatchesSource, sameConnectionHost } from "./connection-hosts";
|
||||
import { readConnectionPreferences } from "./connection-preferences";
|
||||
import {
|
||||
@@ -27,6 +43,10 @@ import {
|
||||
} from "./connection-reconcile";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
import { HomeCardBoundary } from "./HomeCardBoundary";
|
||||
import {
|
||||
buildHomeViewModel,
|
||||
type HomeProjectionReadState,
|
||||
} from "./home-view-model";
|
||||
import {
|
||||
hostBridgeAvailable,
|
||||
hostConnectionApproved,
|
||||
@@ -85,6 +105,17 @@ function stamped(value: string, locale: GuiLocale, t: Translate, suffix = "") {
|
||||
return `${parts.today ? t("apps.today") : ""}${parts.clock}${suffix}`;
|
||||
}
|
||||
|
||||
function projectionTimeBasisLabel(
|
||||
basis: string | null,
|
||||
t: Translate,
|
||||
): string | null {
|
||||
if (basis === "conversation") return t("apps.conversationTime");
|
||||
if (basis === "import") return t("apps.importTime");
|
||||
if (basis === "write") return t("apps.writeTime");
|
||||
if (basis === "update") return t("apps.updateTime");
|
||||
return null;
|
||||
}
|
||||
|
||||
type ActivityChoice = "call" | "update";
|
||||
|
||||
function sourceMatchesHost(sourceId: string, hostId: string | null) {
|
||||
@@ -167,7 +198,6 @@ function SourceMark({ source }: { source: SourceResponse }) {
|
||||
export function ApplicationsHome({
|
||||
imports,
|
||||
onManage,
|
||||
onMemories,
|
||||
onCalls,
|
||||
active = true,
|
||||
}: {
|
||||
@@ -192,6 +222,11 @@ export function ApplicationsHome({
|
||||
const hostStale = hostQuery.isError && hostQuery.data !== undefined;
|
||||
const auto = useAutoConnect();
|
||||
const sedimentation = useSedimentation();
|
||||
const confirmedMode = useConfirmedMemoryMode();
|
||||
const [projection, setProjection] = useState<HomeProjectionV1 | null>(null);
|
||||
const [projectionState, setProjectionState] =
|
||||
useState<HomeProjectionReadState>("loading");
|
||||
const [legacyCore, setLegacyCore] = useState(false);
|
||||
const [summary, setSummary] = useState<HostHomeSummary | null>(null);
|
||||
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
|
||||
null,
|
||||
@@ -278,6 +313,7 @@ export function ApplicationsHome({
|
||||
const [expanded, setExpanded] = useState(retained.expanded);
|
||||
const [phase, setPhase] = useState("idle");
|
||||
const [revision, setRevision] = useState(0);
|
||||
const confirmedModeSeen = useRef(confirmedMode);
|
||||
const expandButton = useRef<HTMLButtonElement>(null);
|
||||
const backButton = useRef<HTMLButtonElement>(null);
|
||||
const root = useRef<HTMLElement>(null);
|
||||
@@ -538,6 +574,18 @@ export function ApplicationsHome({
|
||||
.map((s) => `${s.source_id}:${s.authorized}:${s.grant_revision}`)
|
||||
.join("|");
|
||||
const requestKey = `${sourceVersion}:${revision}`;
|
||||
useEffect(() => {
|
||||
const previous = confirmedModeSeen.current;
|
||||
if (previous.revision === confirmedMode.revision) return;
|
||||
confirmedModeSeen.current = confirmedMode;
|
||||
// The first confirmed mode accompanies the first self-consistent
|
||||
// projection. There is no old-mode projection to hide yet.
|
||||
if (previous.mode === null) return;
|
||||
setProjection(null);
|
||||
setSummary(null);
|
||||
setProjectionState("loading");
|
||||
if (active) setRevision((value) => value + 1);
|
||||
}, [active, confirmedMode]);
|
||||
// requestKey retriggers this read when the source snapshot or a manual retry changes.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: requestKey is the refresh token
|
||||
useEffect(() => {
|
||||
@@ -547,8 +595,9 @@ export function ApplicationsHome({
|
||||
async function refresh() {
|
||||
if (busy || document.hidden) return;
|
||||
busy = true;
|
||||
const homeTicket = issueConfirmedModeTicket("home");
|
||||
const [home, state, sync] = await Promise.allSettled([
|
||||
getHomeSummary(),
|
||||
getHomeProjection(),
|
||||
getMemoryProcessing(),
|
||||
getLiveSyncHealth(),
|
||||
refreshSedimentation(),
|
||||
@@ -556,17 +605,99 @@ export function ApplicationsHome({
|
||||
busy = false;
|
||||
if (stopped) return;
|
||||
if (home.status === "fulfilled") {
|
||||
// stale=true 表示这轮读取超时,数字不代表现状:保留上一次的概览,
|
||||
// 等 10 秒后的下一轮轮询补上。重复 stale 仍要进入可恢复状态,
|
||||
// 不能永远停在“正在核对”。
|
||||
if (!("stale" in home.value) || !home.value.stale) {
|
||||
setSummaryFailures(0);
|
||||
setSummary(home.value);
|
||||
} else {
|
||||
setSummaryFailures((count) => count + 1);
|
||||
}
|
||||
if (
|
||||
!commitConfirmedMemoryMode(
|
||||
home.value.effective_mode,
|
||||
homeTicket,
|
||||
home.value.capabilities,
|
||||
)
|
||||
)
|
||||
return;
|
||||
setLegacyCore(false);
|
||||
setSummaryFailures(0);
|
||||
setProjection(home.value);
|
||||
setProjectionState("ready");
|
||||
} else {
|
||||
setSummaryFailures((count) => count + 1);
|
||||
if (homeProjectionUnsupported(home.reason)) {
|
||||
setLegacyCore(true);
|
||||
try {
|
||||
const modeTicket = issueConfirmedModeTicket("home");
|
||||
const modeResult = await getMemoryMode();
|
||||
if (stopped) return;
|
||||
const modeAccepted = commitConfirmedMemoryMode(
|
||||
modeResult.mode,
|
||||
modeTicket,
|
||||
);
|
||||
const effectiveMode = modeAccepted
|
||||
? modeResult.mode
|
||||
: confirmedMemoryModeSnapshot().mode;
|
||||
if (effectiveMode === "lite") {
|
||||
const raw = await getSdkRaw({ limit: 1 });
|
||||
if (stopped || !isConfirmedModeTicketCurrent(modeTicket)) return;
|
||||
setProjection({
|
||||
projection_version: 1,
|
||||
user_id: raw.user_id ?? "local",
|
||||
effective_mode: "lite",
|
||||
capabilities: {
|
||||
content_plane: "raw",
|
||||
generation_required: false,
|
||||
graph_enabled: false,
|
||||
evolve_available: false,
|
||||
},
|
||||
visible_count: raw.total,
|
||||
display_state: raw.total > 0 ? "no_user_excerpt" : "empty",
|
||||
latest: null,
|
||||
observed_at: new Date().toISOString(),
|
||||
});
|
||||
setSummaryFailures(0);
|
||||
setProjectionState("ready");
|
||||
return;
|
||||
}
|
||||
const legacy = await getHomeSummary();
|
||||
if (stopped || !isConfirmedModeTicketCurrent(modeTicket)) return;
|
||||
if (!("stale" in legacy) || !legacy.stale) {
|
||||
setSummary(legacy);
|
||||
setSummaryFailures(0);
|
||||
setProjectionState("ready");
|
||||
} else {
|
||||
setSummaryFailures((count) => {
|
||||
const next = count + 1;
|
||||
setProjectionState((current) =>
|
||||
next < 2
|
||||
? current
|
||||
: current === "ready" || current === "stale"
|
||||
? "stale"
|
||||
: "unavailable",
|
||||
);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setSummaryFailures((count) => {
|
||||
const next = count + 1;
|
||||
setProjectionState((current) =>
|
||||
next < 2
|
||||
? current
|
||||
: current === "ready" || current === "stale"
|
||||
? "stale"
|
||||
: "unavailable",
|
||||
);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setSummaryFailures((count) => {
|
||||
const next = count + 1;
|
||||
setProjectionState((current) =>
|
||||
next < 2
|
||||
? current
|
||||
: current === "ready" || current === "stale"
|
||||
? "stale"
|
||||
: "unavailable",
|
||||
);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
if (state.status === "fulfilled") setProcessing(state.value);
|
||||
if (sync.status === "fulfilled") setSyncHealth(sync.value);
|
||||
@@ -582,10 +713,31 @@ export function ApplicationsHome({
|
||||
stop();
|
||||
};
|
||||
}, [active, requestKey]);
|
||||
const visible = summary;
|
||||
const latest = visible?.latest;
|
||||
const summaryUnavailable = visible === null && summaryFailures >= 2;
|
||||
const summaryStale = visible !== null && summaryFailures >= 2;
|
||||
const modeChangedPending =
|
||||
confirmedModeSeen.current.mode !== null &&
|
||||
confirmedModeSeen.current.revision !== confirmedMode.revision;
|
||||
const effectiveProjection = modeChangedPending ? null : projection;
|
||||
const visible = modeChangedPending ? null : summary;
|
||||
const historyImporting = imports.jobs.some(
|
||||
(job) => job.status === "running" || job.status === "pending",
|
||||
);
|
||||
const viewModel = buildHomeViewModel({
|
||||
projection: effectiveProjection,
|
||||
projectionState: modeChangedPending ? "loading" : projectionState,
|
||||
confirmedMode: confirmedMode.mode,
|
||||
legacySummary: legacyCore ? visible : null,
|
||||
processing,
|
||||
sedimentation: sedimentation ?? null,
|
||||
recentCall,
|
||||
importsRunning: historyImporting,
|
||||
hostState,
|
||||
syncEnabledCount: imports.sources.filter(
|
||||
(source) => source.authorized && source.sync_enabled,
|
||||
).length,
|
||||
});
|
||||
const latest = viewModel.recent;
|
||||
const summaryUnavailable = viewModel.state === "unavailable";
|
||||
const summaryStale = viewModel.state === "stale";
|
||||
const hasCall =
|
||||
recentCall !== null &&
|
||||
(callState === "ready" || callState === "incomplete");
|
||||
@@ -599,9 +751,6 @@ export function ApplicationsHome({
|
||||
activityView !== null && availableActivities.includes(activityView)
|
||||
? activityView
|
||||
: null;
|
||||
const historyImporting = imports.jobs.some(
|
||||
(job) => job.status === "running" || job.status === "pending",
|
||||
);
|
||||
const recentCallSourceId = (recentCall?.references ?? []).find(
|
||||
(reference) => reference.source_id,
|
||||
)?.source_id;
|
||||
@@ -616,72 +765,46 @@ export function ApplicationsHome({
|
||||
: null;
|
||||
const attention = callAttention(callEvents);
|
||||
const attentionHost = attention ? callHostName(attention.host, locale) : null;
|
||||
const baseActivity =
|
||||
viewModel.activity.kind === "update"
|
||||
? ("memory" as const)
|
||||
: viewModel.activity.kind;
|
||||
const activity = summaryUnavailable
|
||||
? "unavailable"
|
||||
: visible === null
|
||||
? "loading"
|
||||
: pinned === "call"
|
||||
? "call"
|
||||
: pinned === "update"
|
||||
: pinned === "call"
|
||||
? "call"
|
||||
: pinned === "update"
|
||||
? updateKind
|
||||
: updateKind
|
||||
? updateKind
|
||||
: updateKind
|
||||
? updateKind
|
||||
: hasCall
|
||||
? "call"
|
||||
: callState === "unavailable"
|
||||
? "unavailable"
|
||||
: historyImporting
|
||||
? "importing"
|
||||
: callState === "loading"
|
||||
? recentCall !== null
|
||||
? "call"
|
||||
: "loading"
|
||||
: processing?.running
|
||||
? "first-running"
|
||||
: processing?.pending || processing?.waiting_for_model
|
||||
? "first-queued"
|
||||
: "empty";
|
||||
: hasCall
|
||||
? "call"
|
||||
: callState === "unavailable"
|
||||
? "unavailable"
|
||||
: callState === "loading" && recentCall !== null
|
||||
? "call"
|
||||
: baseActivity;
|
||||
const apps = imports.sources.filter(
|
||||
(s) => s.authorized || s.detected === true || s.location_state === "found",
|
||||
);
|
||||
const stateLabel =
|
||||
sedimentation?.enabled === true
|
||||
? t("apps.running")
|
||||
: sedimentation?.enabled === false
|
||||
? t("apps.paused")
|
||||
: t("apps.checkingStatus");
|
||||
const stateDetail =
|
||||
sedimentation?.enabled === false
|
||||
? t("apps.stoppedNew")
|
||||
: sedimentation?.enabled === true
|
||||
? t("apps.organizingNew")
|
||||
: t("apps.checkingProcessing");
|
||||
const latestUpdatedAt =
|
||||
latest === null || latest === undefined
|
||||
? null
|
||||
: "revision" in latest
|
||||
? latest.updated_at
|
||||
: latest.gmt_created === null
|
||||
? null
|
||||
: new Date(latest.gmt_created * 1000).toISOString();
|
||||
const stateLabel = t(viewModel.processingLabel as never);
|
||||
const stateDetail = t(viewModel.processingDetail as never);
|
||||
const latestUpdatedAt = latest?.time ?? null;
|
||||
const latestClock = latestUpdatedAt
|
||||
? clockParts(latestUpdatedAt, locale)
|
||||
: null;
|
||||
const latestSourceIds =
|
||||
latest === null || latest === undefined
|
||||
? []
|
||||
: "revision" in latest
|
||||
? latest.source_ids
|
||||
: latest.source
|
||||
? [latest.source]
|
||||
: [];
|
||||
const latestSourceIds = latest?.sourceId ? [latest.sourceId] : [];
|
||||
const latestSourceNames = latestSourceIds.map(
|
||||
(id) =>
|
||||
imports.sources.find((source) => source.source_id === id)?.display_name ??
|
||||
id,
|
||||
);
|
||||
const time = latestClock?.clock ?? (latest ? t("apps.unknownTime") : null);
|
||||
const checked = visible ? clockParts(visible.observed_at, locale) : null;
|
||||
const checked = effectiveProjection
|
||||
? clockParts(effectiveProjection.observed_at, locale)
|
||||
: visible
|
||||
? clockParts(visible.observed_at, locale)
|
||||
: null;
|
||||
// A failed read leaves the last health in place on purpose: clearing it would
|
||||
// drop the instance it names, and that instance is what keeps memory writes
|
||||
// and backfill receipts attached to the run that produced them. So the card
|
||||
@@ -784,7 +907,7 @@ export function ApplicationsHome({
|
||||
? tn("apps.enabledCount", authorizedSyncSources.length)
|
||||
: syncApplyFailed
|
||||
? t("apps.enableFailed")
|
||||
: wantsSync || !visible
|
||||
: wantsSync || viewModel.state === "loading"
|
||||
? t("apps.checkingState")
|
||||
: t("apps.notEnabled");
|
||||
function toggleAll(next: boolean) {
|
||||
@@ -832,29 +955,31 @@ export function ApplicationsHome({
|
||||
</header>
|
||||
<div className="r6-home-metrics">
|
||||
<div role="status" aria-live="polite" aria-atomic="true">
|
||||
<p>{t("apps.memories")}</p>
|
||||
<p>{t(viewModel.countLabel)}</p>
|
||||
<p className="r6-home-figure">
|
||||
<strong>
|
||||
{new Intl.NumberFormat(locale).format(
|
||||
visible?.memory_count ?? 0,
|
||||
)}
|
||||
{viewModel.count === null
|
||||
? "—"
|
||||
: new Intl.NumberFormat(locale).format(viewModel.count)}
|
||||
</strong>
|
||||
{t("apps.unit") ? <i>{t("apps.unit")}</i> : null}
|
||||
{viewModel.count !== null && t("apps.unit") ? (
|
||||
<i>{t("apps.unit")}</i>
|
||||
) : null}
|
||||
</p>
|
||||
<small>
|
||||
{visible
|
||||
{viewModel.state !== "loading"
|
||||
? summaryStale
|
||||
? t("apps.showingPrevious")
|
||||
: t("apps.scope")
|
||||
: t(viewModel.countDetail)
|
||||
: summaryUnavailable
|
||||
? t("apps.countWillUpdate")
|
||||
: summaryFailures
|
||||
? t("apps.startingRetry")
|
||||
: t("apps.readingCount")}
|
||||
: t(viewModel.countDetail)}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<p>{t("apps.processing")}</p>
|
||||
<p>{t(viewModel.processingTitle)}</p>
|
||||
<strong className="r6-home-word">{stateLabel}</strong>
|
||||
<small>{stateDetail}</small>
|
||||
</div>
|
||||
@@ -862,16 +987,14 @@ export function ApplicationsHome({
|
||||
<p>{t("apps.recentUpdate")}</p>
|
||||
<strong className={time ? "r6-home-time" : "r6-home-word"}>
|
||||
{time ??
|
||||
(visible
|
||||
? t("apps.notGenerated")
|
||||
: t("apps.checkingRecent"))}
|
||||
(viewModel.recentMetric.valueKey
|
||||
? t(viewModel.recentMetric.valueKey)
|
||||
: "")}
|
||||
</strong>
|
||||
<small>
|
||||
{time
|
||||
? `${latestClock?.today ? `${t("apps.today")}· ` : ""}${t("apps.saved")}`
|
||||
: visible
|
||||
? t("apps.separate")
|
||||
: t("apps.recentWillUpdate")}
|
||||
? `${latestClock?.today ? `${t("apps.today")}· ` : ""}${t(viewModel.recentMetric.detailKey)}`
|
||||
: t(viewModel.recentMetric.detailKey)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -905,7 +1028,7 @@ export function ApplicationsHome({
|
||||
? t("apps.recentCall")
|
||||
: activity === "memory"
|
||||
? t("apps.recentUpdate")
|
||||
: t("apps.firstBatch")}
|
||||
: t(viewModel.activity.headingKey)}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
@@ -935,7 +1058,13 @@ export function ApplicationsHome({
|
||||
})()
|
||||
) : null}
|
||||
<section>
|
||||
<h3>
|
||||
<h3
|
||||
className={
|
||||
activity === "memory" && latest?.kind === "raw"
|
||||
? "r6-home-raw-excerpt"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{activity === "unavailable"
|
||||
? callState === "unavailable"
|
||||
? t("apps.callsUnavailable")
|
||||
@@ -947,16 +1076,12 @@ export function ApplicationsHome({
|
||||
locale,
|
||||
)
|
||||
: activity === "memory" && latest
|
||||
? `${latest.content}${"truncated" in latest && latest.truncated ? "…" : ""}`
|
||||
: activity === "importing"
|
||||
? t("apps.importing")
|
||||
: activity === "first-running"
|
||||
? t("apps.firstRunning")
|
||||
: activity === "first-queued"
|
||||
? t("apps.firstQueued")
|
||||
: activity === "loading"
|
||||
? t("apps.readingActivity")
|
||||
: t("apps.emptyMemories")}
|
||||
? latest.emptyExcerpt
|
||||
? t("apps.rawNoExcerpt")
|
||||
: latest.excerpt
|
||||
: viewModel.activity.titleKey
|
||||
? t(viewModel.activity.titleKey)
|
||||
: ""}
|
||||
</h3>
|
||||
<small>
|
||||
{activity === "unavailable"
|
||||
@@ -966,14 +1091,24 @@ export function ApplicationsHome({
|
||||
: activity === "call" && recentCall
|
||||
? `${recentCallHostName} · ${stamped(recentCall.occurred_at, locale, t)} · ${callRowDetail(recentCall, locale)}${callState === "incomplete" ? t("apps.incompleteCalls") : ""}`
|
||||
: activity === "memory" && latest
|
||||
? [time, latestSourceNames.join(", ")]
|
||||
? (latest.kind === "raw"
|
||||
? [
|
||||
t("apps.rawConversation"),
|
||||
latestSourceNames.join(", "),
|
||||
projectionTimeBasisLabel(latest.timeBasis, t),
|
||||
time,
|
||||
]
|
||||
: [
|
||||
time,
|
||||
latestSourceNames.join(", "),
|
||||
projectionTimeBasisLabel(latest.timeBasis, t),
|
||||
]
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: activity === "importing"
|
||||
? t("apps.savingConversations")
|
||||
: activity === "loading"
|
||||
? t("apps.recentWillUpdate")
|
||||
: t("apps.waitingMemories")}
|
||||
: viewModel.activity.detailKey
|
||||
? t(viewModel.activity.detailKey)
|
||||
: ""}
|
||||
</small>
|
||||
</section>
|
||||
{(activity === "unavailable" ||
|
||||
@@ -987,16 +1122,12 @@ export function ApplicationsHome({
|
||||
setRevision((n) => n + 1);
|
||||
setCallRevision((n) => n + 1);
|
||||
}
|
||||
: activity === "call"
|
||||
? onCalls
|
||||
: () => onMemories(latest?.memory_id)
|
||||
: onCalls
|
||||
}
|
||||
>
|
||||
{activity === "unavailable"
|
||||
? t("apps.reread")
|
||||
: activity === "call"
|
||||
? t("apps.viewCalls")
|
||||
: t("apps.viewMemory")}
|
||||
: t("apps.viewCalls")}
|
||||
{activity === "unavailable" ? null : (
|
||||
<ArrowRight size={14} aria-hidden="true" />
|
||||
)}
|
||||
@@ -1049,8 +1180,12 @@ export function ApplicationsHome({
|
||||
<dd>{syncState}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("apps.organizing")}</dt>
|
||||
<dd>{stateLabel}</dd>
|
||||
<dt>{t(viewModel.organizingTitle)}</dt>
|
||||
<dd>{t(viewModel.organizingLabel as never)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("apps.currentMode")}</dt>
|
||||
<dd>{viewModel.currentModeLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("apps.access")}</dt>
|
||||
|
||||
@@ -159,7 +159,9 @@ it("traduce el historial en vivo sin cambiar nombres ni contenido importado", as
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Historial de uso" }),
|
||||
).toBeTruthy();
|
||||
expect(first.textContent).toBe("Cursor encontró 3 contenidos relacionados");
|
||||
expect(first.textContent).toBe(
|
||||
"Cursor encontró 1 memoria · 2 conversaciones originales",
|
||||
);
|
||||
fireEvent.click(first);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Esta consulta de memorias" }),
|
||||
@@ -364,7 +366,7 @@ it("shows exact identity, timestamp, result counts and metadata-only references"
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(detail).getByText((value) =>
|
||||
value.includes("Cursor 查到 3 条相关内容"),
|
||||
value.includes("Cursor 找到 1 条记忆 · 2 条原始会话"),
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(within(detail).getAllByText("source-1").length).toBeGreaterThan(0);
|
||||
@@ -465,13 +467,9 @@ it("keeps detail bound to event ID across refresh reordering, then removes missi
|
||||
});
|
||||
|
||||
it.each([
|
||||
["grounded", "查询成功", "直接调用(诊断) 查到 0 条相关内容"],
|
||||
["grounded", "查询成功", "直接调用(诊断) 调用已完成"],
|
||||
["empty", "查询成功,没有找到相关记忆", "直接调用(诊断) 没有查到相关记忆"],
|
||||
[
|
||||
"partial",
|
||||
"查询成功,部分内容还在整理中",
|
||||
"直接调用(诊断) 查到 0 条相关内容",
|
||||
],
|
||||
["partial", "查询成功,部分内容还在整理中", "直接调用(诊断) 调用已完成"],
|
||||
[
|
||||
"timed_out",
|
||||
"查询超时,没有提供记忆",
|
||||
@@ -546,11 +544,13 @@ it.each(["om_recall", "om_conversations", "om_ask", "om_matters"] as const)(
|
||||
mount();
|
||||
expect(
|
||||
await screen.findByRole("button", {
|
||||
name: "Cursor 查到 3 条相关内容",
|
||||
name: "Cursor 找到 1 条记忆 · 2 条原始会话",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Cursor 查到 3 条相关内容" }),
|
||||
screen.getByRole("button", {
|
||||
name: "Cursor 找到 1 条记忆 · 2 条原始会话",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
within(screen.getByRole("article")).getByText("查记忆"),
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type {
|
||||
EmbeddingSwitchCurrent,
|
||||
EmbeddingSwitchJob,
|
||||
EmbeddingSwitchPlan,
|
||||
} from "@one-memory/contracts";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
const cancelCurrentSwitch = vi.fn();
|
||||
let current: EmbeddingSwitchCurrent | null = null;
|
||||
|
||||
vi.mock("./use-embedding-switch", () => ({
|
||||
useEmbeddingSwitch: () => ({
|
||||
current,
|
||||
busy: null,
|
||||
cancelCurrentSwitch,
|
||||
}),
|
||||
}));
|
||||
vi.mock("./api", () => ({
|
||||
deleteRetiredEmbeddingSpace: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
EmbeddingSwitchConfirmDialog,
|
||||
EmbeddingSwitchGlobalStatus,
|
||||
} from "./EmbeddingSwitchExperience";
|
||||
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
|
||||
|
||||
const NOW = "2026-11-03T09:00:00Z";
|
||||
const space = (id: string | null, model: string) => ({
|
||||
space_id: id,
|
||||
model,
|
||||
dims: model === "old-search" ? 512 : 1024,
|
||||
collection_base: id ? "memory" : "",
|
||||
vectors: id ? 8421 : null,
|
||||
});
|
||||
const plan: EmbeddingSwitchPlan = {
|
||||
needed: true,
|
||||
reason: "embedding_identity_changed",
|
||||
plan_token: "token",
|
||||
source: space("space-old", "old-search"),
|
||||
target: space(null, "new-search"),
|
||||
target_profile_id: "profile-1",
|
||||
target_revision: 2,
|
||||
target_provider_kind: "openai_compatible",
|
||||
target_local: false,
|
||||
sources: [
|
||||
{
|
||||
space_id: "space-old",
|
||||
collection_base: "memory",
|
||||
dims: 512,
|
||||
vectors: 8421,
|
||||
unmerged: null,
|
||||
},
|
||||
],
|
||||
total_vectors: 8421,
|
||||
users: 1,
|
||||
graph_vectors: null,
|
||||
estimate_s: 120,
|
||||
requests: 43,
|
||||
disk_bytes_needed: 72 * 1024 * 1024,
|
||||
disk_bytes_free: 1024 * 1024 * 1024,
|
||||
retention_days: 14,
|
||||
blockers: [],
|
||||
};
|
||||
const job: EmbeddingSwitchJob = {
|
||||
job_id: "job-1",
|
||||
kind: "switch",
|
||||
state: "reembedding",
|
||||
source: space("space-old", "old-search"),
|
||||
target: space("space-new", "new-search"),
|
||||
stray_space_ids: [],
|
||||
target_profile_id: "profile-1",
|
||||
target_revision: 2,
|
||||
requested_by: "app",
|
||||
progress: {
|
||||
phase: "vectors",
|
||||
done: 4884,
|
||||
total: 8421,
|
||||
percent: 58,
|
||||
embedded: 4884,
|
||||
reused: 0,
|
||||
kept: 0,
|
||||
role: "memory",
|
||||
source_index: 0,
|
||||
sources: 1,
|
||||
rate_per_s: 90,
|
||||
eta_s: 52,
|
||||
updated_at: NOW,
|
||||
},
|
||||
cancel_requested: false,
|
||||
error_code: null,
|
||||
attempt: 1,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
finished_at: null,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
current = null;
|
||||
localStorage.removeItem(LANGUAGE_STORAGE_KEY);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("switches Chinese, Spanish, and English without leaking Chinese", async () => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans");
|
||||
const view = render(
|
||||
<LanguageProvider>
|
||||
<EmbeddingSwitchConfirmDialog
|
||||
plan={{ ...plan, users: 2 }}
|
||||
busy={false}
|
||||
errorCode={null}
|
||||
onCancel={vi.fn()}
|
||||
onStart={vi.fn()}
|
||||
/>
|
||||
</LanguageProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "更新记忆搜索索引?" }),
|
||||
).toBeTruthy(),
|
||||
);
|
||||
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "¿Actualizar el índice de búsqueda de memorias?",
|
||||
}),
|
||||
).toBeTruthy(),
|
||||
);
|
||||
expect(view.container.textContent).not.toMatch(/[一-鿿]/);
|
||||
expect(view.container.textContent).toContain("8421");
|
||||
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "en");
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "Update the memory search index?",
|
||||
}),
|
||||
).toBeTruthy(),
|
||||
);
|
||||
expect(view.container.textContent).not.toMatch(/[一-鿿]/);
|
||||
expect(view.container.textContent).toContain("8,421");
|
||||
});
|
||||
|
||||
it("explains the index update in user terms before exposing technical details", async () => {
|
||||
const onStart = vi.fn();
|
||||
render(
|
||||
<EmbeddingSwitchConfirmDialog
|
||||
plan={plan}
|
||||
busy={false}
|
||||
errorCode={null}
|
||||
onCancel={vi.fn()}
|
||||
onStart={onStart}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "更新记忆搜索索引?" }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("8,421 条")).toBeTruthy();
|
||||
expect(screen.getByText("约 2 分钟")).toBeTruthy();
|
||||
expect(screen.getByText(/失败或取消时仍使用原来的模型/)).toBeTruthy();
|
||||
await userEvent.click(screen.getByText("查看技术详情"));
|
||||
expect(screen.getByText("512 → 1024")).toBeTruthy();
|
||||
await userEvent.click(screen.getByRole("button", { name: "开始更新索引" }));
|
||||
expect(onStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks start when Core reports a prerequisite", () => {
|
||||
render(
|
||||
<EmbeddingSwitchConfirmDialog
|
||||
plan={{ ...plan, blockers: ["insufficient_disk_space"] }}
|
||||
busy={false}
|
||||
errorCode={null}
|
||||
onCancel={vi.fn()}
|
||||
onStart={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/剩余空间不足/)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "开始更新索引" })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a running switch visible and cancellable outside settings", async () => {
|
||||
current = {
|
||||
schema_version: 1,
|
||||
phase: "running",
|
||||
writes_paused: true,
|
||||
scan_state: "done",
|
||||
scan_error: null,
|
||||
job,
|
||||
last_finished: null,
|
||||
};
|
||||
render(
|
||||
<EmbeddingSwitchGlobalStatus
|
||||
onConfigureSearch={vi.fn()}
|
||||
onDiagnostics={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("正在更新记忆索引")).toBeTruthy();
|
||||
expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe(
|
||||
"58",
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "取消更新" }));
|
||||
expect(cancelCurrentSwitch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows startup index inspection without blocking the rest of the app", () => {
|
||||
current = {
|
||||
schema_version: 1,
|
||||
phase: "scanning",
|
||||
writes_paused: true,
|
||||
scan_state: "scanning",
|
||||
scan_error: null,
|
||||
job: null,
|
||||
last_finished: null,
|
||||
};
|
||||
render(
|
||||
<EmbeddingSwitchGlobalStatus
|
||||
onConfigureSearch={vi.fn()}
|
||||
onDiagnostics={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("正在检查本机的记忆搜索索引")).toBeTruthy();
|
||||
expect(screen.getByText(/搜索仍可使用/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("explains rollback safety and gives recovery actions", async () => {
|
||||
current = {
|
||||
schema_version: 1,
|
||||
phase: "running",
|
||||
writes_paused: true,
|
||||
scan_state: "done",
|
||||
scan_error: null,
|
||||
job,
|
||||
last_finished: null,
|
||||
};
|
||||
const onConfigureSearch = vi.fn();
|
||||
const onDiagnostics = vi.fn();
|
||||
const view = render(
|
||||
<EmbeddingSwitchGlobalStatus
|
||||
onConfigureSearch={onConfigureSearch}
|
||||
onDiagnostics={onDiagnostics}
|
||||
/>,
|
||||
);
|
||||
|
||||
current = {
|
||||
schema_version: 1,
|
||||
phase: "idle",
|
||||
writes_paused: false,
|
||||
scan_state: "done",
|
||||
scan_error: null,
|
||||
job: null,
|
||||
last_finished: {
|
||||
...job,
|
||||
state: "rolled_back",
|
||||
error_code: "embedding_service_failed",
|
||||
progress: { ...job.progress, phase: "done" },
|
||||
finished_at: NOW,
|
||||
},
|
||||
};
|
||||
view.rerender(
|
||||
<EmbeddingSwitchGlobalStatus
|
||||
onConfigureSearch={onConfigureSearch}
|
||||
onDiagnostics={onDiagnostics}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/记忆没有丢失/)).toBeTruthy());
|
||||
await userEvent.click(screen.getByRole("button", { name: "检查连接" }));
|
||||
expect(onConfigureSearch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -0,0 +1,480 @@
|
||||
import type {
|
||||
EmbeddingSwitchCurrent,
|
||||
EmbeddingSwitchJob,
|
||||
EmbeddingSwitchPlan,
|
||||
} from "@one-memory/contracts";
|
||||
import type { GuiLocale, MessageKey } from "@one-memory/i18n";
|
||||
import { ChevronDown, CircleAlert, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { deleteRetiredEmbeddingSpace } from "./api";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import { useEmbeddingSwitch } from "./use-embedding-switch";
|
||||
import "./embedding-switch.css";
|
||||
|
||||
type Translator = (
|
||||
key: MessageKey,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
function count(locale: GuiLocale, value: number | null | undefined) {
|
||||
return new Intl.NumberFormat(locale).format(value ?? 0);
|
||||
}
|
||||
|
||||
function duration(locale: GuiLocale, t: Translator, seconds: number | null) {
|
||||
if (seconds === null) return t("embeddingSwitch.duration.unknown");
|
||||
const value = Math.max(1, Math.round(seconds < 60 ? seconds : seconds / 60));
|
||||
return t(
|
||||
seconds < 60
|
||||
? "embeddingSwitch.duration.seconds"
|
||||
: "embeddingSwitch.duration.minutes",
|
||||
{ count: count(locale, value) },
|
||||
);
|
||||
}
|
||||
|
||||
function size(locale: GuiLocale, bytes: number) {
|
||||
const megabytes = bytes >= 1024 * 1024;
|
||||
const value = Math.ceil(bytes / 1024 / (megabytes ? 1024 : 1));
|
||||
return `${count(locale, value)} ${megabytes ? "MB" : "KB"}`;
|
||||
}
|
||||
|
||||
const blockerKeys: Record<string, MessageKey> = {
|
||||
embedding_probe_required: "embeddingSwitch.blocker.embedding_probe_required",
|
||||
embedding_dimensions_unknown:
|
||||
"embeddingSwitch.blocker.embedding_dimensions_unknown",
|
||||
embedding_switch_requires_migration:
|
||||
"embeddingSwitch.blocker.embedding_switch_requires_migration",
|
||||
sdk_reembed_unavailable: "embeddingSwitch.blocker.sdk_reembed_unavailable",
|
||||
insufficient_disk_space: "embeddingSwitch.blocker.insufficient_disk_space",
|
||||
embedding_switch_running: "embeddingSwitch.blocker.embedding_switch_running",
|
||||
memory_lifecycle_busy: "embeddingSwitch.blocker.memory_lifecycle_busy",
|
||||
};
|
||||
|
||||
const failureKeys: Record<string, MessageKey> = {
|
||||
writes_did_not_drain: "embeddingSwitch.failure.writes_did_not_drain",
|
||||
embedding_service_failed: "embeddingSwitch.failure.embedding_service_failed",
|
||||
embedding_dimensions_mismatch:
|
||||
"embeddingSwitch.failure.embedding_dimensions_mismatch",
|
||||
embedding_verification_failed:
|
||||
"embeddingSwitch.failure.embedding_verification_failed",
|
||||
target_profile_changed: "embeddingSwitch.failure.target_profile_changed",
|
||||
embedding_switch_cancelled:
|
||||
"embeddingSwitch.failure.embedding_switch_cancelled",
|
||||
model_secret_unavailable: "embeddingSwitch.failure.model_secret_unavailable",
|
||||
secure_storage_unavailable:
|
||||
"embeddingSwitch.failure.secure_storage_unavailable",
|
||||
embedding_switch_clients_busy:
|
||||
"embeddingSwitch.failure.embedding_switch_clients_busy",
|
||||
};
|
||||
|
||||
export function EmbeddingSwitchConfirmDialog({
|
||||
plan,
|
||||
busy,
|
||||
errorCode,
|
||||
onCancel,
|
||||
onStart,
|
||||
}: {
|
||||
plan: EmbeddingSwitchPlan;
|
||||
busy: boolean;
|
||||
errorCode: string | null;
|
||||
onCancel(): void;
|
||||
onStart(): void;
|
||||
}) {
|
||||
const { locale, t } = useGuiLanguage();
|
||||
const panel = useRef<HTMLElement>(null);
|
||||
const blockers = plan.blockers.map((code) =>
|
||||
t(blockerKeys[code] ?? "embeddingSwitch.blocker.default"),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const opener =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
panel.current
|
||||
?.querySelector<HTMLButtonElement>("button:not(:disabled)")
|
||||
?.focus();
|
||||
return () => {
|
||||
if (opener?.isConnected) opener.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="r6-settings-dialog-layer embedding-switch-dialog-layer"
|
||||
role="presentation"
|
||||
>
|
||||
<section
|
||||
ref={panel}
|
||||
className="r6-settings-dialog embedding-switch-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="embedding-switch-title"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" && !busy) onCancel();
|
||||
if (event.key !== "Tab" || !panel.current) return;
|
||||
const controls = [
|
||||
...panel.current.querySelectorAll<HTMLElement>(
|
||||
"button:not(:disabled), summary, input:not(:disabled), select:not(:disabled)",
|
||||
),
|
||||
];
|
||||
const first = controls[0];
|
||||
const last = controls.at(-1);
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<h2 id="embedding-switch-title">
|
||||
{t("embeddingSwitch.confirm.title")}
|
||||
</h2>
|
||||
<p>{t("embeddingSwitch.confirm.description")}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<dl className="embedding-switch-impact">
|
||||
<div>
|
||||
<dt>{t("embeddingSwitch.impact.memories")}</dt>
|
||||
<dd>
|
||||
{t("embeddingSwitch.impact.memoryCount", {
|
||||
count: count(locale, plan.total_vectors),
|
||||
})}
|
||||
{plan.users && plan.users > 1
|
||||
? t("embeddingSwitch.impact.userCount", {
|
||||
count: count(locale, plan.users),
|
||||
})
|
||||
: ""}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("embeddingSwitch.impact.duration")}</dt>
|
||||
<dd>{duration(locale, t, plan.estimate_s)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("embeddingSwitch.impact.requests")}</dt>
|
||||
<dd>
|
||||
{plan.target_local
|
||||
? t("embeddingSwitch.requests.none")
|
||||
: plan.requests === null
|
||||
? t("embeddingSwitch.requests.billed")
|
||||
: t("embeddingSwitch.requests.approx", {
|
||||
count: count(locale, plan.requests),
|
||||
})}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="embedding-switch-safety">
|
||||
<strong>{t("embeddingSwitch.safety.searchTitle")}</strong>
|
||||
<p>{t("embeddingSwitch.safety.searchBody")}</p>
|
||||
<strong>{t("embeddingSwitch.safety.dataTitle")}</strong>
|
||||
<p>{t("embeddingSwitch.safety.dataBody")}</p>
|
||||
</div>
|
||||
|
||||
<details className="embedding-switch-details">
|
||||
<summary>
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
{t("embeddingSwitch.details.title")}
|
||||
</summary>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>{t("embeddingSwitch.details.currentModel")}</dt>
|
||||
<dd>
|
||||
{plan.source.model ?? t("embeddingSwitch.details.legacyIndex")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("embeddingSwitch.details.newModel")}</dt>
|
||||
<dd>
|
||||
{plan.target.model ?? t("embeddingSwitch.details.pending")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("embeddingSwitch.details.dimensions")}</dt>
|
||||
<dd>
|
||||
{plan.source.dims ?? t("embeddingSwitch.details.unknown")} →{" "}
|
||||
{plan.target.dims ?? t("embeddingSwitch.details.unknown")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("embeddingSwitch.details.disk")}</dt>
|
||||
<dd>{size(locale, plan.disk_bytes_needed)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("embeddingSwitch.details.retention")}</dt>
|
||||
<dd>
|
||||
{t("embeddingSwitch.details.days", {
|
||||
count: count(locale, plan.retention_days),
|
||||
})}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</details>
|
||||
|
||||
{blockers.length > 0 ? (
|
||||
<div className="embedding-switch-alert" role="alert">
|
||||
<CircleAlert size={16} aria-hidden="true" />
|
||||
<span>{blockers.join(" ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{errorCode ? (
|
||||
<div className="embedding-switch-alert" role="alert">
|
||||
<CircleAlert size={16} aria-hidden="true" />
|
||||
<span>
|
||||
{errorCode === "embedding_switch_plan_stale"
|
||||
? t("embeddingSwitch.error.planStale")
|
||||
: t("embeddingSwitch.error.startFailed")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<footer>
|
||||
<R6Button disabled={busy} onClick={onCancel}>
|
||||
{t("embeddingSwitch.action.notNow")}
|
||||
</R6Button>
|
||||
<R6Button
|
||||
variant="primary"
|
||||
pending={busy}
|
||||
disabled={blockers.length > 0}
|
||||
onClick={onStart}
|
||||
>
|
||||
{t("embeddingSwitch.action.start")}
|
||||
</R6Button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function jobTitle(t: Translator, job: EmbeddingSwitchJob) {
|
||||
if (job.kind === "merge") return t("embeddingSwitch.job.merge");
|
||||
if (job.state === "preparing") return t("embeddingSwitch.job.preparing");
|
||||
if (job.state === "reembedding") return t("embeddingSwitch.job.reembedding");
|
||||
if (job.state === "verifying") return t("embeddingSwitch.job.verifying");
|
||||
if (job.state === "committing") return t("embeddingSwitch.job.committing");
|
||||
if (job.state === "failed") return t("embeddingSwitch.job.failed");
|
||||
return t("embeddingSwitch.job.default");
|
||||
}
|
||||
|
||||
function canCancel(job: EmbeddingSwitchJob) {
|
||||
return ["preparing", "reembedding", "verifying"].includes(job.state);
|
||||
}
|
||||
|
||||
function ActiveSwitchBanner({
|
||||
current,
|
||||
onCancel,
|
||||
cancelling,
|
||||
}: {
|
||||
current: EmbeddingSwitchCurrent;
|
||||
onCancel(): void;
|
||||
cancelling: boolean;
|
||||
}) {
|
||||
const { locale, t } = useGuiLanguage();
|
||||
const job = current.job;
|
||||
if (!job && current.phase !== "scanning") return null;
|
||||
const percent = job?.progress.percent ?? 0;
|
||||
return (
|
||||
<aside className="embedding-switch-strip" aria-live="polite">
|
||||
<div className="embedding-switch-strip-copy">
|
||||
<strong>
|
||||
{job ? jobTitle(t, job) : t("embeddingSwitch.job.scanning")}
|
||||
</strong>
|
||||
<span>
|
||||
{job?.state === "failed"
|
||||
? t("embeddingSwitch.job.failedSafety")
|
||||
: job
|
||||
? `${new Intl.NumberFormat(locale, {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(percent / 100)}${
|
||||
job.progress.eta_s === null
|
||||
? ""
|
||||
: ` · ${duration(locale, t, job.progress.eta_s)}`
|
||||
}`
|
||||
: t("embeddingSwitch.job.scanPaused")}
|
||||
</span>
|
||||
</div>
|
||||
{job ? (
|
||||
<div
|
||||
className="embedding-switch-progress"
|
||||
role="progressbar"
|
||||
aria-label={t("embeddingSwitch.progress.aria")}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(percent)}
|
||||
>
|
||||
<span style={{ width: `${Math.max(2, percent)}%` }} />
|
||||
</div>
|
||||
) : null}
|
||||
{job && canCancel(job) ? (
|
||||
<R6Button variant="quiet" pending={cancelling} onClick={onCancel}>
|
||||
{t("embeddingSwitch.action.cancel")}
|
||||
</R6Button>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmbeddingSwitchGlobalStatus({
|
||||
onConfigureSearch,
|
||||
onDiagnostics,
|
||||
}: {
|
||||
onConfigureSearch(): void;
|
||||
onDiagnostics(): void;
|
||||
}) {
|
||||
const { t } = useGuiLanguage();
|
||||
const controller = useEmbeddingSwitch();
|
||||
const [recent, setRecent] = useState<EmbeddingSwitchJob | null>(null);
|
||||
const [cleaning, setCleaning] = useState(false);
|
||||
const [cleanupError, setCleanupError] = useState(false);
|
||||
const previousJob = useRef<string | null>(null);
|
||||
const alive = useRef(true);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
alive.current = false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const open = controller.current?.job?.job_id ?? null;
|
||||
if (
|
||||
previousJob.current &&
|
||||
!open &&
|
||||
controller.current?.last_finished?.job_id === previousJob.current
|
||||
) {
|
||||
setRecent(controller.current.last_finished);
|
||||
}
|
||||
previousJob.current = open;
|
||||
}, [controller.current]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{controller.current ? (
|
||||
<ActiveSwitchBanner
|
||||
current={controller.current}
|
||||
cancelling={controller.busy === "cancel"}
|
||||
onCancel={() => void controller.cancelCurrentSwitch()}
|
||||
/>
|
||||
) : null}
|
||||
{controller.refreshFailed && controller.supported === true ? (
|
||||
<aside className="embedding-switch-outcome is-error" role="status">
|
||||
<div>
|
||||
<strong>{t("embeddingSwitch.refresh.title")}</strong>
|
||||
<span>{t("embeddingSwitch.refresh.body")}</span>
|
||||
</div>
|
||||
<R6Button variant="quiet" onClick={() => void controller.refresh()}>
|
||||
{t("embeddingSwitch.action.refresh")}
|
||||
</R6Button>
|
||||
</aside>
|
||||
) : null}
|
||||
{controller.current &&
|
||||
["failed", "unavailable"].includes(controller.current.scan_state) ? (
|
||||
<aside className="embedding-switch-outcome is-error" role="alert">
|
||||
<div>
|
||||
<strong>{t("embeddingSwitch.scan.title")}</strong>
|
||||
<span>
|
||||
{controller.current.writes_paused
|
||||
? t("embeddingSwitch.scan.paused")
|
||||
: t("embeddingSwitch.scan.recovered")}
|
||||
</span>
|
||||
</div>
|
||||
<R6Button variant="quiet" onClick={onDiagnostics}>
|
||||
{t("embeddingSwitch.action.diagnostics")}
|
||||
</R6Button>
|
||||
</aside>
|
||||
) : null}
|
||||
{recent?.state === "done" ? (
|
||||
<aside
|
||||
className="embedding-switch-outcome"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div>
|
||||
<strong>{t("embeddingSwitch.success.title")}</strong>
|
||||
<span>{t("embeddingSwitch.success.body")}</span>
|
||||
</div>
|
||||
<div className="embedding-switch-outcome-actions">
|
||||
{recent.source.space_id ? (
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
pending={cleaning}
|
||||
onClick={() => {
|
||||
if (!recent.source.space_id) return;
|
||||
setCleaning(true);
|
||||
setCleanupError(false);
|
||||
void deleteRetiredEmbeddingSpace(recent.source.space_id)
|
||||
.then(() => {
|
||||
if (alive.current) setRecent(null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive.current) setCleanupError(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive.current) setCleaning(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("embeddingSwitch.action.cleanup")}
|
||||
</R6Button>
|
||||
) : null}
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
aria-label={t("embeddingSwitch.action.closeSuccess")}
|
||||
onClick={() => setRecent(null)}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</R6Button>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
{cleanupError ? (
|
||||
<p className="embedding-switch-cleanup-error" role="alert">
|
||||
{t("embeddingSwitch.cleanup.failed")}
|
||||
</p>
|
||||
) : null}
|
||||
{recent?.state === "rolled_back" ? (
|
||||
<aside
|
||||
className="embedding-switch-outcome is-error"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div>
|
||||
<strong>{t("embeddingSwitch.rollback.title")}</strong>
|
||||
<span>
|
||||
{failureKeys[recent.error_code ?? ""]
|
||||
? t("embeddingSwitch.rollback.reason", {
|
||||
reason: t(failureKeys[recent.error_code ?? ""]),
|
||||
safety: t("embeddingSwitch.rollback.safety"),
|
||||
})
|
||||
: t("embeddingSwitch.rollback.safety")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="embedding-switch-outcome-actions">
|
||||
<R6Button variant="quiet" onClick={onDiagnostics}>
|
||||
{t("embeddingSwitch.action.diagnostics")}
|
||||
</R6Button>
|
||||
<R6Button variant="quiet" onClick={onConfigureSearch}>
|
||||
{t("embeddingSwitch.action.checkConnection")}
|
||||
</R6Button>
|
||||
<R6Button
|
||||
variant="quiet"
|
||||
aria-label={t("embeddingSwitch.action.closeFailure")}
|
||||
onClick={() => setRecent(null)}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</R6Button>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -239,7 +239,7 @@ it("emits controlled connection drafts only and passes continuation without comp
|
||||
screen.getByRole<HTMLInputElement>("checkbox", { name: "一键接入应用" })
|
||||
.checked,
|
||||
).toBe(false);
|
||||
fireEvent.click(screen.getByRole("button", { name: "配置模型,沉淀记忆" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "选择记忆模式" }));
|
||||
expect(next).toHaveBeenCalledWith("settings");
|
||||
});
|
||||
|
||||
@@ -379,8 +379,8 @@ it("uses user-facing connection confirmation copy without implementation narrati
|
||||
expect(
|
||||
screen.queryByText(/选择草稿|按各应用回执|本页任务控制尚未接通/),
|
||||
).toBeNull();
|
||||
expect(screen.getByText(/不会自动开放应用读取权限/)).toBeTruthy();
|
||||
expect(screen.getByText(/配置模型后,才会开始生成记忆/)).toBeTruthy();
|
||||
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
|
||||
expect(screen.getByText(/之后仍可在设置中更改/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each(["paused", "failed", "cancelled", "interrupted"] as const)(
|
||||
|
||||
@@ -147,7 +147,7 @@ it("explains that one large harness limits every first-import source to three da
|
||||
it("continues to step3 while sample GET is pending without calling completion APIs", () => {
|
||||
api.receipt.mockReturnValue(new Promise(() => {}));
|
||||
const { props } = setup();
|
||||
fireEvent.click(screen.getByRole("button", { name: "配置模型,沉淀记忆" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "选择记忆模式" }));
|
||||
expect(props.onContinue).toHaveBeenCalledWith("settings");
|
||||
expect(screen.queryByText(/^已生成.*记忆/)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -373,7 +373,7 @@ export function FirstImportReview({
|
||||
disabled={busy}
|
||||
onClick={() => leave("settings")}
|
||||
>
|
||||
{text("配置模型,沉淀记忆")}
|
||||
{text("选择记忆模式")}
|
||||
</R6Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -393,9 +393,7 @@ export function FirstImportReview({
|
||||
)}
|
||||
</p>
|
||||
<p className="first-import-r6-subintro">
|
||||
{text(
|
||||
"导入不会自动开放应用读取权限。配置模型后,才会开始生成记忆。",
|
||||
)}
|
||||
{text("下一步选择记忆模式;之后仍可在设置中更改。")}
|
||||
</p>
|
||||
{historyWindowLimited && (
|
||||
<p className="first-import-r6-history-window" role="status">
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
import type { ProviderKind } from "@one-memory/contracts";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import { OptionMenu } from "./OptionMenu";
|
||||
import { PROVIDER_PRESETS } from "./provider-presets";
|
||||
|
||||
export interface GenerationModelValues {
|
||||
provider_kind: ProviderKind;
|
||||
model: string;
|
||||
base_url: string;
|
||||
timeout_seconds: string;
|
||||
max_concurrency: string;
|
||||
context_window: string;
|
||||
disable_thinking: boolean;
|
||||
}
|
||||
|
||||
export type GenerationModelField = keyof GenerationModelValues | "api_key";
|
||||
export type GenerationModelErrors = Partial<
|
||||
Record<GenerationModelField, string>
|
||||
>;
|
||||
|
||||
export const GENERATION_PROTOCOLS: readonly {
|
||||
value: ProviderKind;
|
||||
label: string;
|
||||
supported: boolean;
|
||||
}[] = [
|
||||
{
|
||||
value: "openai_compatible",
|
||||
label: "OpenAI-compatible",
|
||||
supported: true,
|
||||
},
|
||||
{ value: "ollama", label: "Ollama(本机)", supported: true },
|
||||
{ value: "anthropic", label: "Anthropic(暂不支持)", supported: false },
|
||||
{ value: "hy_default", label: "HY 托管(当前不可用)", supported: false },
|
||||
];
|
||||
|
||||
export function presetForGeneration(profile: {
|
||||
provider_kind: string;
|
||||
base_url: string | null;
|
||||
}): string {
|
||||
return (
|
||||
PROVIDER_PRESETS.find(
|
||||
(preset) =>
|
||||
preset.provider === profile.provider_kind &&
|
||||
endpointKey(preset.baseUrl) === endpointKey(profile.base_url ?? ""),
|
||||
)?.id ?? "custom"
|
||||
);
|
||||
}
|
||||
|
||||
export function generationProviderLabel(provider: ProviderKind): string {
|
||||
return (
|
||||
GENERATION_PROTOCOLS.find((item) => item.value === provider)?.label ??
|
||||
provider
|
||||
);
|
||||
}
|
||||
|
||||
export function generationSummary(
|
||||
presetId: string,
|
||||
provider: ProviderKind,
|
||||
model: string,
|
||||
text: (value: string) => string,
|
||||
): string {
|
||||
const preset = PROVIDER_PRESETS.find((item) => item.id === presetId);
|
||||
return `${preset ? text(preset.label) : text(generationProviderLabel(provider))} · ${
|
||||
model.trim() || text("模型待填写")
|
||||
}`;
|
||||
}
|
||||
|
||||
export function validGenerationEndpoint(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
["http:", "https:"].includes(url.protocol) &&
|
||||
!!url.hostname &&
|
||||
!url.username &&
|
||||
!url.password
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateGenerationModel(
|
||||
values: GenerationModelValues,
|
||||
options: {
|
||||
apiKey: string;
|
||||
keyRequired: boolean;
|
||||
requireRuntimeValues?: boolean;
|
||||
},
|
||||
): GenerationModelErrors {
|
||||
const errors: GenerationModelErrors = {};
|
||||
if (!values.model.trim()) errors.model = "请填写实际可用的模型 ID。";
|
||||
if (!validGenerationEndpoint(values.base_url))
|
||||
errors.base_url = "请填写有效的模型接口地址。";
|
||||
if (
|
||||
options.apiKey.trim() &&
|
||||
/^(?:[a-z][a-z\d+.-]*:\/\/|www\.|\/\/)/i.test(options.apiKey.trim())
|
||||
)
|
||||
errors.api_key = "这里需要 API Key,不是网址。请填写服务方提供的密钥。";
|
||||
else if (options.keyRequired && !options.apiKey.trim())
|
||||
errors.api_key = "请填写 API Key。";
|
||||
|
||||
const integer = (
|
||||
key: "timeout_seconds" | "max_concurrency" | "context_window",
|
||||
min: number,
|
||||
max: number,
|
||||
) => {
|
||||
const raw = values[key].trim();
|
||||
if (!raw && !options.requireRuntimeValues) return;
|
||||
const value = Number(raw);
|
||||
if (!raw || !Number.isInteger(value) || value < min || value > max)
|
||||
errors[key] = "onboardingIntegerRange";
|
||||
};
|
||||
integer("timeout_seconds", 1, 600);
|
||||
integer("max_concurrency", 1, 32);
|
||||
if (values.context_window.trim()) integer("context_window", 1024, 10_000_000);
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function ProviderPresetField({
|
||||
id,
|
||||
labelledBy,
|
||||
ariaLabel,
|
||||
describedBy,
|
||||
value,
|
||||
disabled,
|
||||
text,
|
||||
includePlaceholder = false,
|
||||
onChange,
|
||||
}: {
|
||||
id?: string;
|
||||
labelledBy?: string;
|
||||
ariaLabel?: string;
|
||||
describedBy?: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
text: (value: string) => string;
|
||||
includePlaceholder?: boolean;
|
||||
onChange(value: string): void;
|
||||
}) {
|
||||
return (
|
||||
<OptionMenu
|
||||
id={id}
|
||||
labelledBy={labelledBy}
|
||||
ariaLabel={ariaLabel}
|
||||
describedBy={describedBy}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
options={[
|
||||
...(includePlaceholder && !value
|
||||
? [{ value: "", label: text("选择服务商"), disabled: true }]
|
||||
: []),
|
||||
...PROVIDER_PRESETS.map((preset) => ({
|
||||
value: preset.id,
|
||||
label: text(preset.label),
|
||||
})),
|
||||
{ value: "custom", label: text("自定义兼容服务") },
|
||||
]}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function GenerationTechnicalDisclosure({
|
||||
open,
|
||||
disabled,
|
||||
controls,
|
||||
text,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
disabled?: boolean;
|
||||
controls: string;
|
||||
text: (value: string) => string;
|
||||
onChange(open: boolean): void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="generation-technical-disclosure">
|
||||
<button
|
||||
type="button"
|
||||
className="generation-technical-toggle"
|
||||
aria-expanded={open}
|
||||
aria-controls={controls}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!open)}
|
||||
>
|
||||
{text("配置详情与高级设置")}
|
||||
{open ? (
|
||||
<ChevronUp size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDown size={16} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
<div id={controls} className="generation-technical-fields" hidden={!open}>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function GenerationTechnicalFields({
|
||||
idPrefix,
|
||||
values,
|
||||
errors = {},
|
||||
disabled,
|
||||
text,
|
||||
modelRef,
|
||||
onChange,
|
||||
}: {
|
||||
idPrefix: string;
|
||||
values: GenerationModelValues;
|
||||
errors?: GenerationModelErrors;
|
||||
disabled?: boolean;
|
||||
text: (value: string) => string;
|
||||
modelRef?: Ref<HTMLInputElement>;
|
||||
onChange(patch: Partial<GenerationModelValues>): void;
|
||||
}) {
|
||||
const field = (
|
||||
key:
|
||||
| "model"
|
||||
| "base_url"
|
||||
| "timeout_seconds"
|
||||
| "max_concurrency"
|
||||
| "context_window",
|
||||
label: string,
|
||||
options: {
|
||||
type?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
placeholder?: string;
|
||||
ref?: Ref<HTMLInputElement>;
|
||||
} = {},
|
||||
) => {
|
||||
const error = errors[key];
|
||||
const inputId = `${idPrefix}-${key}`;
|
||||
return (
|
||||
<label className="generation-model-field" htmlFor={inputId}>
|
||||
<span>{text(label)}</span>
|
||||
<input
|
||||
ref={options.ref}
|
||||
id={inputId}
|
||||
name={key}
|
||||
aria-label={text(label)}
|
||||
type={options.type ?? "text"}
|
||||
inputMode={options.type === "url" ? "url" : undefined}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={values[key]}
|
||||
disabled={disabled}
|
||||
min={options.min}
|
||||
max={options.max}
|
||||
maxLength={key === "model" ? 256 : undefined}
|
||||
placeholder={
|
||||
options.placeholder ? text(options.placeholder) : undefined
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? `${inputId}-error` : undefined}
|
||||
onChange={(event) => onChange({ [key]: event.target.value })}
|
||||
/>
|
||||
{error ? (
|
||||
<small id={`${inputId}-error`} className="generation-model-error">
|
||||
{text(error)}
|
||||
</small>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<label
|
||||
className="generation-model-field"
|
||||
htmlFor={`${idPrefix}-protocol`}
|
||||
>
|
||||
<span>{text("协议")}</span>
|
||||
<select
|
||||
id={`${idPrefix}-protocol`}
|
||||
name="provider"
|
||||
aria-label={text("协议")}
|
||||
value={values.provider_kind}
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
onChange({ provider_kind: event.target.value as ProviderKind })
|
||||
}
|
||||
>
|
||||
{GENERATION_PROTOCOLS.map((protocol) => (
|
||||
<option
|
||||
key={protocol.value}
|
||||
value={protocol.value}
|
||||
disabled={!protocol.supported}
|
||||
>
|
||||
{text(protocol.label)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{field("model", "生成模型名", {
|
||||
placeholder: "例如:gpt-4o-mini…",
|
||||
ref: modelRef,
|
||||
})}
|
||||
{field("base_url", "Base URL", { type: "url" })}
|
||||
<div className="generation-runtime-fields">
|
||||
{field("timeout_seconds", "超时(秒)", {
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 600,
|
||||
placeholder: "自动 / 可选",
|
||||
})}
|
||||
{field("max_concurrency", "最大并发", {
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 32,
|
||||
placeholder: "自动 / 可选",
|
||||
})}
|
||||
{field("context_window", "上下文窗口", {
|
||||
type: "number",
|
||||
min: 1024,
|
||||
max: 10_000_000,
|
||||
placeholder: "自动 / 可选",
|
||||
})}
|
||||
</div>
|
||||
<small className="generation-runtime-note">
|
||||
{text("留空使用 Core 默认值。")}
|
||||
</small>
|
||||
<label className="generation-thinking">
|
||||
<input
|
||||
name="disable-thinking"
|
||||
type="checkbox"
|
||||
checked={values.disable_thinking}
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
onChange({ disable_thinking: event.target.checked })
|
||||
}
|
||||
/>
|
||||
{text("关闭思考模式")}
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function endpointKey(value: string): string {
|
||||
return value.trim().replace(/\/+$/, "").toLowerCase();
|
||||
}
|
||||
@@ -153,6 +153,58 @@ describe("ImportProgressStrip", () => {
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("uses raw-saving copy and never offers model setup in lite mode", () => {
|
||||
render(
|
||||
<ImportProgressStrip
|
||||
jobs={[job()]}
|
||||
sources={sources}
|
||||
backfilling={false}
|
||||
onOpenSources={vi.fn()}
|
||||
onConfigureModel={vi.fn()}
|
||||
modeCapabilities={{
|
||||
content_plane: "raw",
|
||||
generation_required: false,
|
||||
graph_enabled: false,
|
||||
evolve_available: false,
|
||||
}}
|
||||
arrival={{
|
||||
importing: true,
|
||||
importedMessages: 3,
|
||||
importedConversations: 1,
|
||||
phase: "model-required",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("status").textContent).toContain(
|
||||
"正在保存原始对话",
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "设置模型" })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not keep a lite progress strip mounted without active work", () => {
|
||||
const { container } = render(
|
||||
<ImportProgressStrip
|
||||
jobs={[]}
|
||||
sources={sources}
|
||||
backfilling={false}
|
||||
onOpenSources={vi.fn()}
|
||||
modeCapabilities={{
|
||||
content_plane: "raw",
|
||||
generation_required: false,
|
||||
graph_enabled: false,
|
||||
evolve_available: false,
|
||||
}}
|
||||
arrival={{
|
||||
importing: false,
|
||||
importedMessages: 3,
|
||||
importedConversations: 1,
|
||||
phase: "stopped",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("shows one plain sentence for first history import without details", () => {
|
||||
render(
|
||||
<ImportProgressStrip
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
HomeProjectionCapabilities,
|
||||
ImportIssue,
|
||||
ImportJob,
|
||||
SourceResponse,
|
||||
@@ -68,6 +69,7 @@ export interface ImportProgressStripProps {
|
||||
showSourceLink?: boolean;
|
||||
showProgressDetails?: boolean;
|
||||
userFacing?: boolean;
|
||||
modeCapabilities?: HomeProjectionCapabilities | null;
|
||||
}
|
||||
|
||||
/** Named sources and confirmed counts, without claiming cached jobs are live. */
|
||||
@@ -85,6 +87,7 @@ export function ImportProgressStrip({
|
||||
showSourceLink = true,
|
||||
showProgressDetails = true,
|
||||
userFacing = false,
|
||||
modeCapabilities,
|
||||
}: ImportProgressStripProps) {
|
||||
const { t, tn } = useGuiLanguage();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@@ -212,7 +215,13 @@ export function ImportProgressStrip({
|
||||
),
|
||||
);
|
||||
const currentArrival = arrival
|
||||
? resolveMemoryArrival({ arrival, processing: null, sources, loaded: true })
|
||||
? resolveMemoryArrival({
|
||||
arrival,
|
||||
processing: null,
|
||||
sources,
|
||||
loaded: true,
|
||||
capabilities: modeCapabilities,
|
||||
})
|
||||
: null;
|
||||
const needsAction =
|
||||
currentArrival &&
|
||||
@@ -253,11 +262,14 @@ export function ImportProgressStrip({
|
||||
(sum, source) => sum + source.imported_conversations,
|
||||
0,
|
||||
);
|
||||
const message = firstImport
|
||||
? tn("importUserProgress", imported)
|
||||
: currentArrival?.phase === "model-required"
|
||||
? t("importUserModel")
|
||||
: t("importUserIncomplete");
|
||||
const message =
|
||||
modeCapabilities?.generation_required === false
|
||||
? t("apps.rawSaving")
|
||||
: firstImport
|
||||
? tn("importUserProgress", imported)
|
||||
: currentArrival?.phase === "model-required"
|
||||
? t("importUserModel")
|
||||
: t("importUserIncomplete");
|
||||
return (
|
||||
<div
|
||||
className="import-strip"
|
||||
@@ -272,16 +284,18 @@ export function ImportProgressStrip({
|
||||
)}
|
||||
<strong>{message}</strong>
|
||||
</div>
|
||||
{!firstImport && onConfigureModel && (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onConfigureModel}
|
||||
>
|
||||
<Settings2 size={15} aria-hidden="true" />
|
||||
{t("importUserSettings")}
|
||||
</button>
|
||||
)}
|
||||
{!firstImport &&
|
||||
modeCapabilities?.generation_required !== false &&
|
||||
onConfigureModel && (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={onConfigureModel}
|
||||
>
|
||||
<Settings2 size={15} aria-hidden="true" />
|
||||
{t("importUserSettings")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -322,19 +336,21 @@ export function ImportProgressStrip({
|
||||
? updating
|
||||
? "已保存内容保留,正在核对进度"
|
||||
: "进度待更新"
|
||||
: currentArrival && needsAction
|
||||
? currentArrival.phase === "model-required"
|
||||
? "待继续"
|
||||
: memoryArrivalTitle(currentArrival)
|
||||
: generating
|
||||
? "正在沉淀记忆"
|
||||
: importing || currentArrival?.phase === "importing"
|
||||
? running.length > 0 && running.every(isSyncJob)
|
||||
? "正在检查新增对话"
|
||||
: "正在导入对话"
|
||||
: backfilling
|
||||
? "正在准备整理"
|
||||
: "待继续";
|
||||
: modeCapabilities?.generation_required === false
|
||||
? t("apps.rawSaving")
|
||||
: currentArrival && needsAction
|
||||
? currentArrival.phase === "model-required"
|
||||
? "待继续"
|
||||
: memoryArrivalTitle(currentArrival)
|
||||
: generating
|
||||
? "正在沉淀记忆"
|
||||
: importing || currentArrival?.phase === "importing"
|
||||
? running.length > 0 && running.every(isSyncJob)
|
||||
? "正在检查新增对话"
|
||||
: "正在导入对话"
|
||||
: backfilling
|
||||
? "正在准备整理"
|
||||
: "待继续";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -645,6 +661,7 @@ export function ImportProgressStrip({
|
||||
</button>
|
||||
)}
|
||||
{!stale &&
|
||||
modeCapabilities?.generation_required !== false &&
|
||||
currentArrival?.phase === "model-required" &&
|
||||
onConfigureModel && (
|
||||
<button type="button" className="quiet" onClick={onConfigureModel}>
|
||||
|
||||
@@ -46,6 +46,7 @@ const api = vi.hoisted(() => ({
|
||||
vi.mock("./api", () => api);
|
||||
|
||||
const REQUEST_ID = "11111111-2222-4333-8444-555555555555";
|
||||
const OLD_REQUEST_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
|
||||
const addition: MemoryAddReceipt = {
|
||||
evidence_id: "evidence-1",
|
||||
text: "项目周会不排在周五。",
|
||||
@@ -122,13 +123,14 @@ it("explains source verification failure without retrying or claiming success",
|
||||
expect(screen.queryByRole("button", { name: "重新整理" })).toBeNull();
|
||||
});
|
||||
|
||||
function page() {
|
||||
function page(rawOnly = false) {
|
||||
const onSaved = vi.fn(),
|
||||
onOpenMemory = vi.fn(),
|
||||
onConfigureModel = vi.fn(),
|
||||
onPendingChange = vi.fn();
|
||||
const view = render(
|
||||
<MemoryAddComposer
|
||||
rawOnly={rawOnly}
|
||||
{...{ onSaved, onOpenMemory, onConfigureModel, onPendingChange }}
|
||||
/>,
|
||||
);
|
||||
@@ -198,6 +200,114 @@ it("saves from an empty library with the linked-app scope by default", async ()
|
||||
expect(screen.queryByRole("button", { name: "查看记忆" })).toBeNull();
|
||||
});
|
||||
|
||||
it("saves a lite manual addition as original content without model UI", async () => {
|
||||
api.addMemory.mockResolvedValue({
|
||||
...addition,
|
||||
state: "no_memories",
|
||||
});
|
||||
const callbacks = page(true);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "手动添加" }));
|
||||
expect(screen.getByRole("dialog", { name: "手动添加" })).toBeTruthy();
|
||||
expect(screen.queryByText(/整理模型:/)).toBeNull();
|
||||
expect(api.getModelProfiles).not.toHaveBeenCalled();
|
||||
await userEvent.type(
|
||||
screen.getByRole("textbox", { name: "记下这件事" }),
|
||||
addition.text,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存" }));
|
||||
|
||||
expect(await screen.findByText("已保存原文")).toBeTruthy();
|
||||
expect(callbacks.onSaved).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
callbacks.onPendingChange.mock.calls.some(([value]) => value === null),
|
||||
).toBe(true);
|
||||
expect(screen.queryByRole("dialog", { name: "手动添加" })).toBeNull();
|
||||
});
|
||||
|
||||
it("refreshes the lite raw page when an asynchronous manual save completes", async () => {
|
||||
api.addMemory.mockResolvedValue({
|
||||
...addition,
|
||||
state: "running",
|
||||
});
|
||||
api.getMemoryAddStatus.mockResolvedValue({
|
||||
...addition,
|
||||
state: "no_memories",
|
||||
});
|
||||
const callbacks = page(true);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "手动添加" }));
|
||||
await userEvent.type(screen.getByRole("textbox"), addition.text);
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存" }));
|
||||
|
||||
await waitFor(() => expect(callbacks.onSaved).toHaveBeenCalledTimes(2), {
|
||||
timeout: 2_500,
|
||||
});
|
||||
expect(screen.getByText("已保存原文")).toBeTruthy();
|
||||
expect(callbacks.onPendingChange).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it("does not treat an older completed addition as a newly saved raw turn", async () => {
|
||||
localStorage.setItem(
|
||||
RECENT_ADDITIONS_KEY,
|
||||
JSON.stringify([REQUEST_ID, OLD_REQUEST_ID]),
|
||||
);
|
||||
let currentState: MemoryAddReceipt["state"] = "running";
|
||||
const calls = new Map<string, number>();
|
||||
api.getMemoryAddStatus.mockImplementation(async (requestId: string) => {
|
||||
calls.set(requestId, (calls.get(requestId) ?? 0) + 1);
|
||||
return requestId === OLD_REQUEST_ID
|
||||
? {
|
||||
...addition,
|
||||
evidence_id: "old-evidence",
|
||||
state: "no_memories",
|
||||
}
|
||||
: { ...addition, state: currentState };
|
||||
});
|
||||
const callbacks = page(true);
|
||||
|
||||
await waitFor(
|
||||
() => expect(calls.get(REQUEST_ID) ?? 0).toBeGreaterThanOrEqual(2),
|
||||
{ timeout: 2_500 },
|
||||
);
|
||||
expect(callbacks.onSaved).not.toHaveBeenCalled();
|
||||
|
||||
currentState = "no_memories";
|
||||
await waitFor(() => expect(callbacks.onSaved).toHaveBeenCalledOnce(), {
|
||||
timeout: 2_500,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a failed lite raw write visible after the composer closes", async () => {
|
||||
api.addMemory.mockResolvedValue({
|
||||
...addition,
|
||||
state: "running",
|
||||
});
|
||||
api.getMemoryAddStatus.mockResolvedValue({
|
||||
...addition,
|
||||
state: "failed",
|
||||
});
|
||||
const callbacks = page(true);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "手动添加" }));
|
||||
await userEvent.type(screen.getByRole("textbox"), addition.text);
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存" }));
|
||||
expect(screen.queryByText("已保存原文")).toBeNull();
|
||||
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(callbacks.onPendingChange).toHaveBeenLastCalledWith({
|
||||
evidenceId: addition.evidence_id,
|
||||
text: addition.text,
|
||||
state: "failed",
|
||||
}),
|
||||
{ timeout: 2_500 },
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("原话已保存,整理未完成")).toBeTruthy(),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves draft and request identity after an uncertain save", async () => {
|
||||
api.addMemory
|
||||
.mockRejectedValueOnce(new Error("timeout"))
|
||||
|
||||
@@ -103,12 +103,14 @@ export function MemoryAddComposer({
|
||||
onConfigureModel,
|
||||
onPendingChange,
|
||||
suspended = false,
|
||||
rawOnly = false,
|
||||
}: {
|
||||
onSaved(): void;
|
||||
onOpenMemory(id: string): void;
|
||||
onConfigureModel(): void;
|
||||
onPendingChange?(addition: PendingMemoryAdd | null): void;
|
||||
suspended?: boolean;
|
||||
rawOnly?: boolean;
|
||||
}) {
|
||||
const { text: copy } = useMemoryLanguage();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -128,6 +130,7 @@ export function MemoryAddComposer({
|
||||
const additionsRequest = useRef(0);
|
||||
const intent = useRef<{ text: string; requestId: string } | null>(null);
|
||||
const completed = useRef(new Set<string>());
|
||||
const watched = useRef(new Set<string>());
|
||||
const lastSaved = useRef<string | null>(null);
|
||||
const onSavedRef = useRef(onSaved);
|
||||
onSavedRef.current = onSaved;
|
||||
@@ -156,34 +159,72 @@ export function MemoryAddComposer({
|
||||
}, []);
|
||||
|
||||
const refreshAdditions = useCallback(
|
||||
async (failure: string, notifyResults = false) => {
|
||||
async (
|
||||
failure: string,
|
||||
notifyResults = false,
|
||||
allowWhileClosed = false,
|
||||
) => {
|
||||
const request = ++additionsRequest.current;
|
||||
try {
|
||||
const value = await loadRecentAdditions();
|
||||
if (!isOpen.current || request !== additionsRequest.current) return;
|
||||
if (
|
||||
(!allowWhileClosed && !isOpen.current) ||
|
||||
request !== additionsRequest.current
|
||||
)
|
||||
return;
|
||||
setAdditions(value);
|
||||
reportPending(value);
|
||||
if (!notifyResults) return;
|
||||
if (!notifyResults) {
|
||||
for (const { receipt } of value) {
|
||||
if (
|
||||
receipt.state === "ready" ||
|
||||
(rawOnly && receipt.state === "no_memories")
|
||||
)
|
||||
completed.current.add(receipt.evidence_id);
|
||||
else if (receipt.state === "pending" || receipt.state === "running")
|
||||
watched.current.add(receipt.evidence_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const latest = value.find(
|
||||
(item) => item.receipt.evidence_id === lastSaved.current,
|
||||
);
|
||||
if (latest) setNotice(states[latest.receipt.state]);
|
||||
if (latest) {
|
||||
setNotice(
|
||||
rawOnly && latest.receipt.state === "no_memories"
|
||||
? "已保存原文"
|
||||
: states[latest.receipt.state],
|
||||
);
|
||||
if (rawOnly && latest.receipt.state === "failed")
|
||||
setSavedToast(states.failed);
|
||||
}
|
||||
const newResults = value.filter(
|
||||
({ receipt }) =>
|
||||
receipt.state === "ready" &&
|
||||
(receipt.state === "ready" ||
|
||||
(rawOnly && receipt.state === "no_memories")) &&
|
||||
watched.current.has(receipt.evidence_id) &&
|
||||
!completed.current.has(receipt.evidence_id),
|
||||
);
|
||||
if (newResults.length) {
|
||||
for (const { receipt } of newResults)
|
||||
for (const { receipt } of newResults) {
|
||||
completed.current.add(receipt.evidence_id);
|
||||
watched.current.delete(receipt.evidence_id);
|
||||
}
|
||||
if (rawOnly) setSavedToast("已保存原文");
|
||||
onSavedRef.current();
|
||||
}
|
||||
for (const { receipt } of value)
|
||||
if (receipt.state === "failed")
|
||||
watched.current.delete(receipt.evidence_id);
|
||||
} catch {
|
||||
if (isOpen.current && request === additionsRequest.current)
|
||||
if (
|
||||
(isOpen.current || allowWhileClosed) &&
|
||||
request === additionsRequest.current
|
||||
)
|
||||
setError(failure);
|
||||
}
|
||||
},
|
||||
[reportPending],
|
||||
[rawOnly, reportPending],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -193,6 +234,15 @@ export function MemoryAddComposer({
|
||||
const before = document.activeElement;
|
||||
panel.current?.querySelector("textarea")?.focus();
|
||||
void refreshAdditions("最近添加的内容读取失败。");
|
||||
if (rawOnly) {
|
||||
setModel(null);
|
||||
setModelRead(true);
|
||||
return () => {
|
||||
isOpen.current = false;
|
||||
additionsRequest.current += 1;
|
||||
if (before instanceof HTMLElement && before.isConnected) before.focus();
|
||||
};
|
||||
}
|
||||
setModelRead(false);
|
||||
getModelProfiles()
|
||||
.then((value) => {
|
||||
@@ -211,32 +261,38 @@ export function MemoryAddComposer({
|
||||
additionsRequest.current += 1;
|
||||
if (before instanceof HTMLElement && before.isConnected) before.focus();
|
||||
};
|
||||
}, [open, refreshAdditions, suspended]);
|
||||
}, [open, rawOnly, refreshAdditions, suspended]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open || suspended || !onPendingChange) return;
|
||||
void loadRecentAdditions()
|
||||
.then((value) => reportPending(value))
|
||||
.catch(() => {
|
||||
// The page already keeps the last known state when a background read
|
||||
// fails; do not turn an unknown pending count into zero here.
|
||||
});
|
||||
}, [open, onPendingChange, reportPending, suspended]);
|
||||
void refreshAdditions(
|
||||
"最近添加的内容读取失败。",
|
||||
lastSaved.current !== null,
|
||||
true,
|
||||
);
|
||||
}, [open, onPendingChange, refreshAdditions, suspended]);
|
||||
|
||||
const working = additions.some(
|
||||
({ receipt }) => receipt.state === "pending" || receipt.state === "running",
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!open || suspended || !working) return;
|
||||
if (suspended || !working) return;
|
||||
const stop = subscribePoll({
|
||||
// A key of its own: sharing one with another surface would make one
|
||||
// tick read the wrong endpoint for the other caller.
|
||||
key: "memory-add-processing",
|
||||
intervalMs: 1_500,
|
||||
run: () => refreshAdditions("整理状态暂时不可用,原话已保存。", true),
|
||||
run: () =>
|
||||
refreshAdditions(
|
||||
rawOnly
|
||||
? "保存状态暂时不可用,原文已保存。"
|
||||
: "整理状态暂时不可用,原话已保存。",
|
||||
true,
|
||||
!open,
|
||||
),
|
||||
});
|
||||
return stop;
|
||||
}, [open, working, refreshAdditions, suspended]);
|
||||
}, [open, rawOnly, working, refreshAdditions, suspended]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!savedToast) return;
|
||||
@@ -273,12 +329,27 @@ export function MemoryAddComposer({
|
||||
setNotice("");
|
||||
const trace = traceUiAction("memory", "submit");
|
||||
const completeSave = (receipt: MemoryAddReceipt) => {
|
||||
if (
|
||||
receipt.state === "ready" ||
|
||||
(rawOnly && receipt.state === "no_memories")
|
||||
)
|
||||
completed.current.add(receipt.evidence_id);
|
||||
else if (receipt.state === "pending" || receipt.state === "running")
|
||||
watched.current.add(receipt.evidence_id);
|
||||
remember({ requestId, receipt });
|
||||
lastSaved.current = receipt.evidence_id;
|
||||
setText("");
|
||||
intent.current = null;
|
||||
setNotice("");
|
||||
setSavedToast("已保存,正在后台沉淀记忆");
|
||||
setSavedToast(
|
||||
rawOnly
|
||||
? receipt.state === "ready" || receipt.state === "no_memories"
|
||||
? "已保存原文"
|
||||
: receipt.state === "failed"
|
||||
? states.failed
|
||||
: "正在保存…"
|
||||
: "已保存,正在后台沉淀记忆",
|
||||
);
|
||||
isOpen.current = false;
|
||||
additionsRequest.current += 1;
|
||||
setOpen(false);
|
||||
@@ -321,7 +392,8 @@ export function MemoryAddComposer({
|
||||
data-tour-id="memory-add-trigger"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<FilePlus2 size={16} aria-hidden="true" /> {copy("添加记忆")}
|
||||
<FilePlus2 size={16} aria-hidden="true" />{" "}
|
||||
{copy(rawOnly ? "手动添加" : "添加记忆")}
|
||||
</button>
|
||||
{savedToast && (
|
||||
<p className="memory-add-toast" role="status">
|
||||
@@ -360,11 +432,13 @@ export function MemoryAddComposer({
|
||||
}}
|
||||
>
|
||||
<header className="memory-add-heading">
|
||||
<h2 id="memory-add-title">{copy("添加记忆")}</h2>
|
||||
<h2 id="memory-add-title">
|
||||
{copy(rawOnly ? "手动添加" : "添加记忆")}
|
||||
</h2>
|
||||
<button
|
||||
className="quiet"
|
||||
type="button"
|
||||
aria-label={copy("关闭新增记忆")}
|
||||
aria-label={copy(rawOnly ? "关闭" : "关闭新增记忆")}
|
||||
title={copy("关闭")}
|
||||
disabled={busy}
|
||||
onClick={close}
|
||||
@@ -390,11 +464,13 @@ export function MemoryAddComposer({
|
||||
placeholder={copy("例如:项目周会不排在周五。")}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
/>
|
||||
<p className="memory-add-model">
|
||||
{copy("整理模型:")}
|
||||
{model?.name ??
|
||||
copy(modelRead ? "尚未配置,原话仍可保存" : "正在读取")}
|
||||
</p>
|
||||
{!rawOnly && (
|
||||
<p className="memory-add-model">
|
||||
{copy("整理模型:")}
|
||||
{model?.name ??
|
||||
copy(modelRead ? "尚未配置,原话仍可保存" : "正在读取")}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="error-banner" role="alert">
|
||||
{copy(error)}
|
||||
@@ -402,7 +478,7 @@ export function MemoryAddComposer({
|
||||
)}
|
||||
{notice && <p role="status">{copy(notice)}</p>}
|
||||
<div className="consent-actions">
|
||||
{modelRead && !model && (
|
||||
{!rawOnly && modelRead && !model && (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
@@ -430,7 +506,19 @@ export function MemoryAddComposer({
|
||||
{additions.map(({ requestId, receipt }) => (
|
||||
<li key={requestId}>
|
||||
<p>{receipt.text}</p>
|
||||
<small>{copy(states[receipt.state])}</small>
|
||||
<small>
|
||||
{copy(
|
||||
rawOnly &&
|
||||
(receipt.state === "pending" ||
|
||||
receipt.state === "running")
|
||||
? "正在保存…"
|
||||
: rawOnly &&
|
||||
(receipt.state === "ready" ||
|
||||
receipt.state === "no_memories")
|
||||
? "已保存原文"
|
||||
: states[receipt.state],
|
||||
)}
|
||||
</small>
|
||||
{memoryWriteErrorText(receipt.error) && (
|
||||
<p className="error-banner">
|
||||
{copy(memoryWriteErrorText(receipt.error) ?? "")}
|
||||
|
||||
@@ -6,7 +6,11 @@ import type {
|
||||
} from "@one-memory/contracts";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { MemoryGraph3D } from "./MemoryGraph3D";
|
||||
import {
|
||||
graphNodeTooltip,
|
||||
isGraphNodeSelectable,
|
||||
MemoryGraph3D,
|
||||
} from "./MemoryGraph3D";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -48,6 +52,18 @@ function makeGraph(nodeCount: number): MemoryGraphResponse {
|
||||
}
|
||||
|
||||
describe("MemoryGraph3D", () => {
|
||||
it("keeps topic annotations hover-only", () => {
|
||||
const topic = {
|
||||
...makeGraph(1).nodes[0],
|
||||
node_type: "topic" as const,
|
||||
label: "CI",
|
||||
};
|
||||
expect(isGraphNodeSelectable(topic)).toBe(false);
|
||||
expect(graphNodeTooltip(topic, "主题:")).toBe("主题:CI");
|
||||
expect(graphNodeTooltip(topic, "Topic: ")).toBe("Topic: CI");
|
||||
expect(isGraphNodeSelectable({ ...topic, node_type: "memory" })).toBe(true);
|
||||
});
|
||||
|
||||
it("没有 WebGL 时给出可访问降级提示", () => {
|
||||
render(
|
||||
<MemoryGraph3D
|
||||
|
||||
@@ -27,6 +27,20 @@ const SELECT_DURATION_MS = 180;
|
||||
const SELECTED_COLOR = new THREE.Color(0x294355);
|
||||
const SELECTED_EMISSIVE = new THREE.Color(0x607e97);
|
||||
|
||||
/** Topic nodes explain the graph; unlike memories and sources, they do not own a detail view. */
|
||||
export function isGraphNodeSelectable(node: MemoryGraphNode): boolean {
|
||||
return node.node_type !== "topic";
|
||||
}
|
||||
|
||||
export function graphNodeTooltip(
|
||||
node: MemoryGraphNode,
|
||||
topicPrefix: string,
|
||||
): string {
|
||||
return node.node_type === "topic"
|
||||
? `${topicPrefix}${node.label}`
|
||||
: node.label;
|
||||
}
|
||||
|
||||
export function MemoryGraph3D({
|
||||
graph,
|
||||
selectedNodeId,
|
||||
@@ -36,10 +50,20 @@ export function MemoryGraph3D({
|
||||
selectedNodeId: string | null;
|
||||
onSelectNode(node: MemoryGraphNode): void;
|
||||
}) {
|
||||
const { text } = useMemoryLanguage();
|
||||
const { locale, text } = useMemoryLanguage();
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const labelHost = useRef<HTMLDivElement>(null);
|
||||
const selectRef = useRef(onSelectNode);
|
||||
const nodesByIdRef = useRef(
|
||||
new Map(graph.nodes.map((node) => [node.node_id, node])),
|
||||
);
|
||||
nodesByIdRef.current = new Map(
|
||||
graph.nodes.map((node) => [node.node_id, node]),
|
||||
);
|
||||
const topicPrefixRef = useRef("");
|
||||
topicPrefixRef.current = `${text("主题")}${
|
||||
locale.startsWith("zh") || locale.startsWith("ja") ? ":" : ": "
|
||||
}`;
|
||||
// 选中态通过 ref 传入渲染循环,而不是进 useEffect 依赖。
|
||||
// 依赖里带 selectedNodeId 会导致每次点击重建整个场景、重跑布局、重建全部 mesh。
|
||||
const selectedRef = useRef(selectedNodeId);
|
||||
@@ -157,9 +181,7 @@ export function MemoryGraph3D({
|
||||
}
|
||||
}
|
||||
const labels = graph.nodes
|
||||
.filter(
|
||||
(node) => node.node_type === "source" || node.node_type === "topic",
|
||||
)
|
||||
.filter((node) => node.node_type === "source")
|
||||
.slice(0, 4)
|
||||
.map((node) => {
|
||||
const element = document.createElement("span");
|
||||
@@ -194,7 +216,7 @@ export function MemoryGraph3D({
|
||||
}
|
||||
};
|
||||
|
||||
const selectable: THREE.Mesh[] = [];
|
||||
const hittable: THREE.Mesh[] = [];
|
||||
const materials = new Set<THREE.Material>();
|
||||
const geometryByType = {
|
||||
source: new THREE.SphereGeometry(0.24, 18, 12),
|
||||
@@ -237,7 +259,7 @@ export function MemoryGraph3D({
|
||||
mesh.scale.setScalar(1);
|
||||
mesh.visible = returning || extending;
|
||||
scene.add(mesh);
|
||||
selectable.push(mesh);
|
||||
hittable.push(mesh);
|
||||
visuals.push({
|
||||
mesh,
|
||||
material,
|
||||
@@ -298,7 +320,6 @@ export function MemoryGraph3D({
|
||||
});
|
||||
}
|
||||
|
||||
const nodesById = new Map(graph.nodes.map((node) => [node.node_id, node]));
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const pointer = new THREE.Vector2();
|
||||
const hitAt = (event: PointerEvent) => {
|
||||
@@ -307,7 +328,7 @@ export function MemoryGraph3D({
|
||||
pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
|
||||
pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1;
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
return raycaster.intersectObjects(selectable, false)[0];
|
||||
return raycaster.intersectObjects(hittable, false)[0];
|
||||
};
|
||||
let pressed: { x: number; y: number; id: number; moved: boolean } | null =
|
||||
null;
|
||||
@@ -333,8 +354,9 @@ export function MemoryGraph3D({
|
||||
)
|
||||
return;
|
||||
const nodeId = hitAt(event)?.object.userData.nodeId;
|
||||
const node = typeof nodeId === "string" ? nodesById.get(nodeId) : null;
|
||||
if (node) selectRef.current(node);
|
||||
const node =
|
||||
typeof nodeId === "string" ? nodesByIdRef.current.get(nodeId) : null;
|
||||
if (node && isGraphNodeSelectable(node)) selectRef.current(node);
|
||||
};
|
||||
const hoverNode = (event: PointerEvent) => {
|
||||
if (pressed) {
|
||||
@@ -344,12 +366,18 @@ export function MemoryGraph3D({
|
||||
return;
|
||||
}
|
||||
const nodeId = hitAt(event)?.object.userData.nodeId;
|
||||
const node = typeof nodeId === "string" ? nodesById.get(nodeId) : null;
|
||||
renderer.domElement.style.cursor = node ? "pointer" : "grab";
|
||||
const node =
|
||||
typeof nodeId === "string" ? nodesByIdRef.current.get(nodeId) : null;
|
||||
renderer.domElement.style.cursor =
|
||||
node && isGraphNodeSelectable(node)
|
||||
? "pointer"
|
||||
: node
|
||||
? "default"
|
||||
: "grab";
|
||||
setHovered(
|
||||
node
|
||||
? {
|
||||
label: node.label,
|
||||
label: graphNodeTooltip(node, topicPrefixRef.current),
|
||||
x: event.clientX - element.getBoundingClientRect().left,
|
||||
y: event.clientY - element.getBoundingClientRect().top,
|
||||
}
|
||||
@@ -629,17 +657,23 @@ export function MemoryGraph3D({
|
||||
{!available ? (
|
||||
<div className="memory-graph-fallback">
|
||||
<p>{text("这台设备打不开 3D 视图,已显示关联列表。")}</p>
|
||||
{graph.nodes.map((node) => (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
key={node.node_id}
|
||||
aria-pressed={selectedNodeId === node.node_id}
|
||||
onClick={() => onSelectNode(node)}
|
||||
>
|
||||
{node.label}
|
||||
</button>
|
||||
))}
|
||||
{graph.nodes.map((node) =>
|
||||
isGraphNodeSelectable(node) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
key={node.node_id}
|
||||
aria-pressed={selectedNodeId === node.node_id}
|
||||
onClick={() => onSelectNode(node)}
|
||||
>
|
||||
{node.label}
|
||||
</button>
|
||||
) : (
|
||||
<span className="memory-graph-fallback-topic" key={node.node_id}>
|
||||
{graphNodeTooltip(node, topicPrefixRef.current)}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{available && (
|
||||
|
||||
@@ -149,6 +149,102 @@ it("OBS-22: the graph inspector has no editing entry and never says changes cann
|
||||
expect(screen.queryByText(/暂不能保存更改/)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps topic annotations out of the inspector and opens detail-bearing graph nodes", async () => {
|
||||
const onOpen = vi.fn();
|
||||
const topicGraph = {
|
||||
total_memories: 2,
|
||||
projected_memories: 2,
|
||||
hy_graph_available: true,
|
||||
nodes: [
|
||||
{
|
||||
node_id: "memory:m0",
|
||||
node_type: "memory",
|
||||
memory_id: "m0",
|
||||
label: "第一条",
|
||||
},
|
||||
{
|
||||
node_id: "memory:outside",
|
||||
node_type: "memory",
|
||||
memory_id: "outside",
|
||||
label: "图中但不在当前页的记忆",
|
||||
},
|
||||
{
|
||||
node_id: "topic:git",
|
||||
node_type: "topic",
|
||||
memory_id: null,
|
||||
label: "git",
|
||||
},
|
||||
{
|
||||
node_id: "schema:workflow",
|
||||
node_type: "schema",
|
||||
memory_id: null,
|
||||
label: "先验证,再推送",
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
edge_id: "topic-m0",
|
||||
source_node_id: "topic:git",
|
||||
target_node_id: "memory:m0",
|
||||
relation: "tagged_with",
|
||||
origin: "shared_tag",
|
||||
},
|
||||
{
|
||||
edge_id: "topic-outside",
|
||||
source_node_id: "topic:git",
|
||||
target_node_id: "memory:outside",
|
||||
relation: "tagged_with",
|
||||
origin: "shared_tag",
|
||||
},
|
||||
{
|
||||
edge_id: "schema-m0",
|
||||
source_node_id: "schema:workflow",
|
||||
target_node_id: "memory:m0",
|
||||
relation: "derived_from",
|
||||
origin: "hy_relation",
|
||||
},
|
||||
],
|
||||
} as MemoryGraphResponse;
|
||||
render(
|
||||
<Host
|
||||
simplified
|
||||
items={[memories[0]]}
|
||||
graphValue={topicGraph}
|
||||
onOpenSpy={onOpen}
|
||||
libraryView={pagedView({
|
||||
items: [memories[0]],
|
||||
total: 2,
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
source_ids_by_memory: { m0: ["codex"] },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
onOpen.mockClear();
|
||||
await userEvent.click(screen.getByRole("button", { name: "图中选择 git" }));
|
||||
expect(onOpen).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("heading", { name: "git" })).toBeNull();
|
||||
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
|
||||
expect(screen.getByTestId("canvas").dataset.selected).toBe("memory:m0");
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "图中选择 先验证,再推送" }),
|
||||
);
|
||||
expect(screen.getByRole("heading", { name: "先验证,再推送" })).toBeTruthy();
|
||||
expect(screen.queryByText("关系归纳")).toBeNull();
|
||||
expect(screen.queryByText("归纳自")).toBeNull();
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "图中选择 图中但不在当前页的记忆",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onOpen).toHaveBeenLastCalledWith("outside");
|
||||
expect(screen.getByTestId("canvas").dataset.selected).toBe("memory:outside");
|
||||
expect(screen.queryByText("所选记忆不在当前筛选内,已取消选择。")).toBeNull();
|
||||
});
|
||||
|
||||
it("OBS-19: Enter in the memory filter submits it; submitting it again re-reads", async () => {
|
||||
const onRetry = vi.fn();
|
||||
const onQuery = vi.fn();
|
||||
@@ -391,6 +487,7 @@ function Host({
|
||||
deferMemoryId,
|
||||
failMemoryId,
|
||||
graphValue = graph,
|
||||
onOpenSpy,
|
||||
onViewHistory,
|
||||
onConfigureModel = () => {},
|
||||
libraryView,
|
||||
@@ -408,6 +505,7 @@ function Host({
|
||||
deferMemoryId?: string;
|
||||
failMemoryId?: string;
|
||||
graphValue?: MemoryGraphResponse;
|
||||
onOpenSpy?(memoryId: string): void;
|
||||
onViewHistory?(): void;
|
||||
onConfigureModel?(): void;
|
||||
libraryView?: Parameters<typeof MemoryMap>[0]["libraryView"];
|
||||
@@ -445,6 +543,7 @@ function Host({
|
||||
streaming={null}
|
||||
requestIds={{ current: new Map() }}
|
||||
onOpenMemory={(id) => {
|
||||
onOpenSpy?.(id);
|
||||
if (failMemoryId && id === failMemoryId) {
|
||||
setDetail(null);
|
||||
setDetailLoad({ memoryId: id, status: "failed" });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
GovernedMemory,
|
||||
HomeProjectionCapabilities,
|
||||
HostAskMemoryResponse,
|
||||
MemoryDetail,
|
||||
MemoryGraphNode,
|
||||
@@ -8,6 +9,8 @@ import type {
|
||||
MemoryProcessingSummary,
|
||||
SdkAskMemory,
|
||||
SdkMemoryScene,
|
||||
SdkRawContextItem,
|
||||
SdkRawRecord,
|
||||
SourceResponse,
|
||||
} from "@one-memory/contracts";
|
||||
import type { GuiLocale } from "@one-memory/i18n";
|
||||
@@ -30,6 +33,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
import { HandoffBar } from "./HandoffBar";
|
||||
import { MemoryEvidencePanel } from "./MemoryEvidencePanel";
|
||||
import { MemoryGraph3D } from "./MemoryGraph3D";
|
||||
@@ -48,6 +52,7 @@ import {
|
||||
import { OptionMenu } from "./OptionMenu";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
|
||||
import { SelectedRawDetail } from "./SelectedRawDetail";
|
||||
import { sourceNameOr } from "./source-label";
|
||||
import { useMediaPreference } from "./use-media-preference";
|
||||
import { Answer, NodeDetail, type SavedAnswer } from "./Workbench";
|
||||
@@ -88,7 +93,10 @@ type Props = {
|
||||
hidden?: boolean;
|
||||
id?: string;
|
||||
arrival?: MemoryArrival;
|
||||
modeCapabilities?: HomeProjectionCapabilities | null;
|
||||
onViewHistory?(): void;
|
||||
contentKind?: "memory" | "raw";
|
||||
rawRecord?: SdkRawRecord | SdkRawContextItem | null;
|
||||
libraryView?: {
|
||||
query: string;
|
||||
source: string;
|
||||
@@ -171,6 +179,8 @@ const SCENE_LABELS: Record<SdkMemoryScene, string> = {
|
||||
|
||||
export function MemoryMap(props: Props) {
|
||||
const { locale, text, count, plain } = useMemoryLanguage();
|
||||
const { t } = useGuiLanguage();
|
||||
const raw = props.contentKind === "raw";
|
||||
const {
|
||||
memories,
|
||||
graph: baseGraph,
|
||||
@@ -260,6 +270,7 @@ export function MemoryMap(props: Props) {
|
||||
processing: props.processing,
|
||||
sources,
|
||||
loaded,
|
||||
capabilities: props.modeCapabilities,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!loaded || library) return;
|
||||
@@ -411,6 +422,15 @@ export function MemoryMap(props: Props) {
|
||||
)),
|
||||
) ?? null)
|
||||
: null;
|
||||
const projectedMemoryIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
projection.nodes.flatMap((node) =>
|
||||
node.memory_id ? [node.memory_id] : [],
|
||||
),
|
||||
),
|
||||
[projection.nodes],
|
||||
);
|
||||
const selectedSource =
|
||||
visibleGroupNode?.node_type === "source"
|
||||
? sources.find(
|
||||
@@ -428,9 +448,12 @@ export function MemoryMap(props: Props) {
|
||||
}
|
||||
}, [groupNode, visibleGroupNode]);
|
||||
const requestedId = selectedId ?? detail?.memory.memory_id ?? null;
|
||||
const activeId = active.some((memory) => memory.memory_id === requestedId)
|
||||
? requestedId
|
||||
: null;
|
||||
const activeId =
|
||||
requestedId &&
|
||||
(active.some((memory) => memory.memory_id === requestedId) ||
|
||||
projectedMemoryIds.has(requestedId))
|
||||
? requestedId
|
||||
: null;
|
||||
const inspectorIdentity = activeId
|
||||
? `memory:${activeId}`
|
||||
: selectedSource
|
||||
@@ -440,7 +463,8 @@ export function MemoryMap(props: Props) {
|
||||
const visibleDetail =
|
||||
detail &&
|
||||
(!activeId || activeId === detail.memory.memory_id) &&
|
||||
filtered.some((m) => m.memory_id === detail.memory.memory_id)
|
||||
(filtered.some((m) => m.memory_id === detail.memory.memory_id) ||
|
||||
projectedMemoryIds.has(detail.memory.memory_id))
|
||||
? detail
|
||||
: null;
|
||||
const lastDetail = useRef(visibleDetail);
|
||||
@@ -458,7 +482,8 @@ export function MemoryMap(props: Props) {
|
||||
props.detailLoad.status === "failed";
|
||||
const pick = useCallback(
|
||||
(memoryId: string, scroll = true) => {
|
||||
if (!active.some((memory) => memory.memory_id === memoryId)) return;
|
||||
const listed = active.some((memory) => memory.memory_id === memoryId);
|
||||
if (!listed && !projectedMemoryIds.has(memoryId)) return;
|
||||
if (pendingPageOffset !== null && library?.failed && library.page)
|
||||
library.onPage(library.page.offset);
|
||||
setPendingSourceSelection(null);
|
||||
@@ -467,7 +492,7 @@ export function MemoryMap(props: Props) {
|
||||
pageRetrySawLoading.current = false;
|
||||
setDetailResetRevision((current) => current + 1);
|
||||
scrollSelection.current = scroll;
|
||||
if (!filtered.some((memory) => memory.memory_id === memoryId)) {
|
||||
if (listed && !filtered.some((memory) => memory.memory_id === memoryId)) {
|
||||
setGroupKey("");
|
||||
setSearch("");
|
||||
setFilterNotice("已清除筛选,定位到关联记忆。");
|
||||
@@ -477,7 +502,14 @@ export function MemoryMap(props: Props) {
|
||||
setInspectorOpen(true);
|
||||
onOpenMemory(memoryId);
|
||||
},
|
||||
[active, filtered, library, onOpenMemory, pendingPageOffset],
|
||||
[
|
||||
active,
|
||||
filtered,
|
||||
library,
|
||||
onOpenMemory,
|
||||
pendingPageOffset,
|
||||
projectedMemoryIds,
|
||||
],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -597,12 +629,18 @@ export function MemoryMap(props: Props) {
|
||||
return;
|
||||
if (
|
||||
requestedId &&
|
||||
!active.some((memory) => memory.memory_id === requestedId)
|
||||
!active.some((memory) => memory.memory_id === requestedId) &&
|
||||
!projectedMemoryIds.has(requestedId)
|
||||
) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
if (!activeId || filtered.some((memory) => memory.memory_id === activeId))
|
||||
const listed = active.some((memory) => memory.memory_id === activeId);
|
||||
if (
|
||||
!activeId ||
|
||||
filtered.some((memory) => memory.memory_id === activeId) ||
|
||||
(!listed && projectedMemoryIds.has(activeId))
|
||||
)
|
||||
return;
|
||||
clear();
|
||||
setFilterNotice("所选记忆不在当前筛选内,已取消选择。");
|
||||
@@ -615,6 +653,7 @@ export function MemoryMap(props: Props) {
|
||||
overlaysSuspended,
|
||||
library?.loading,
|
||||
pendingPageOffset,
|
||||
projectedMemoryIds,
|
||||
]);
|
||||
function closeInspector() {
|
||||
if (!compact) {
|
||||
@@ -645,6 +684,9 @@ export function MemoryMap(props: Props) {
|
||||
setGroupKey(key);
|
||||
}
|
||||
function pickNode(node: MemoryGraphNode) {
|
||||
// Topics are graph annotations, not detail-bearing records. They remain
|
||||
// hoverable in the canvas but must not replace the memory inspector.
|
||||
if (node.node_type === "topic") return;
|
||||
if (node.memory_id) {
|
||||
pick(node.memory_id);
|
||||
return;
|
||||
@@ -669,11 +711,6 @@ export function MemoryMap(props: Props) {
|
||||
if (first) pick(first, false);
|
||||
}
|
||||
}
|
||||
if (node.node_type === "topic") {
|
||||
if (props.simplified) return;
|
||||
setAxis("topic");
|
||||
setGroupKey(`topic:${node.label}`);
|
||||
}
|
||||
}
|
||||
const label =
|
||||
group?.label ??
|
||||
@@ -699,6 +736,7 @@ export function MemoryMap(props: Props) {
|
||||
: "";
|
||||
if (
|
||||
props.simplified &&
|
||||
!raw &&
|
||||
(!library ||
|
||||
(!library.loading &&
|
||||
!library.failed &&
|
||||
@@ -779,6 +817,17 @@ export function MemoryMap(props: Props) {
|
||||
value={controlledSource}
|
||||
options={[
|
||||
{ value: "", label: text("全部应用") },
|
||||
...(raw &&
|
||||
!sources.some(
|
||||
(source) => source.source_id === "one-memory-manual",
|
||||
)
|
||||
? [
|
||||
{
|
||||
value: "source:one-memory-manual",
|
||||
label: text("手动添加"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(library?.source &&
|
||||
library.source !== library.unattributedSource &&
|
||||
!sources.some(
|
||||
@@ -860,11 +909,15 @@ export function MemoryMap(props: Props) {
|
||||
>
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input
|
||||
aria-label={text("筛选地图记忆")}
|
||||
aria-label={raw ? t("sdk.raw.searchLabel") : text("筛选地图记忆")}
|
||||
name="map-memory-search"
|
||||
autoComplete="off"
|
||||
placeholder={
|
||||
props.simplified ? text("搜索记忆") : text("筛选记忆…")
|
||||
raw
|
||||
? t("sdk.raw.searchLabel")
|
||||
: props.simplified
|
||||
? text("搜索记忆")
|
||||
: text("筛选记忆…")
|
||||
}
|
||||
value={library ? searchDraft : search}
|
||||
maxLength={library ? 512 : undefined}
|
||||
@@ -971,7 +1024,7 @@ export function MemoryMap(props: Props) {
|
||||
<ul
|
||||
ref={memoryList}
|
||||
className="map-memory-list"
|
||||
aria-label={text("地图记忆列表")}
|
||||
aria-label={raw ? t("memory:原始对话") : text("地图记忆列表")}
|
||||
id="memory-pane-memories"
|
||||
>
|
||||
{filtered.map((m) => (
|
||||
@@ -1014,12 +1067,12 @@ export function MemoryMap(props: Props) {
|
||||
m.updated_at,
|
||||
locale,
|
||||
)}`}
|
||||
{/* D99: the conversation date alone would hide that this
|
||||
one was made today, so the generated day stays too. */}
|
||||
<span className="map-memory-made">
|
||||
{text("沉淀于 ")}
|
||||
{dayStamp(m.updated_at, locale)}
|
||||
</span>
|
||||
{!raw ? (
|
||||
<span className="map-memory-made">
|
||||
{text("沉淀于 ")}
|
||||
{dayStamp(m.updated_at, locale)}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
text("已可用 · {count} 段原文", {
|
||||
@@ -1033,9 +1086,11 @@ export function MemoryMap(props: Props) {
|
||||
</ul>
|
||||
{library?.failed && (
|
||||
<div role="alert">
|
||||
<p>{text("记忆读取失败,请重试。")}</p>
|
||||
<p>
|
||||
{raw ? t("sdk.raw.loadFailed") : text("记忆读取失败,请重试。")}
|
||||
</p>
|
||||
<R6Button onClick={retryLibrary}>
|
||||
{text("重新读取地图记忆")}
|
||||
{raw ? t("connectionFallback.retry") : text("重新读取地图记忆")}
|
||||
</R6Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1045,13 +1100,17 @@ export function MemoryMap(props: Props) {
|
||||
!library.stale &&
|
||||
!filtered.length && (
|
||||
<p className="map-empty">
|
||||
{library.query
|
||||
? text("没有匹配的记忆")
|
||||
: library.source
|
||||
? text("这个来源下还没有记忆")
|
||||
: groupKey
|
||||
? text("本页没有匹配的主题记忆")
|
||||
: text("还没有已生成的记忆")}
|
||||
{raw
|
||||
? library.query
|
||||
? t("sdk.raw.found", { count: 0 })
|
||||
: t("sdk.raw.empty")
|
||||
: library.query
|
||||
? text("没有匹配的记忆")
|
||||
: library.source
|
||||
? text("这个来源下还没有记忆")
|
||||
: groupKey
|
||||
? text("本页没有匹配的主题记忆")
|
||||
: text("还没有已生成的记忆")}
|
||||
</p>
|
||||
)}
|
||||
{library?.page?.search_window &&
|
||||
@@ -1151,12 +1210,20 @@ export function MemoryMap(props: Props) {
|
||||
<span className="map-kicker" hidden={props.simplified}>
|
||||
{text(axis === "topic" ? "主题视角(非事项)" : "来源视角")}
|
||||
</span>
|
||||
<h2>{props.simplified ? text("记忆关联") : label}</h2>
|
||||
<h2>
|
||||
{props.simplified
|
||||
? raw
|
||||
? t("memory:原始对话")
|
||||
: text("记忆关联")
|
||||
: label}
|
||||
</h2>
|
||||
{props.simplified && (
|
||||
<p className="selected-graph-subtitle">
|
||||
{projection.nodes.length
|
||||
? text("来源归属")
|
||||
: text("可在沉淀记忆后查看视图")}
|
||||
: raw
|
||||
? t("sdk.raw.empty")
|
||||
: text("可在沉淀记忆后查看视图")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -1195,21 +1262,33 @@ export function MemoryMap(props: Props) {
|
||||
: {})}
|
||||
>
|
||||
{library?.stale
|
||||
? text("可在沉淀记忆后查看视图")
|
||||
? raw
|
||||
? t("sdk.raw.loadFailed")
|
||||
: text("可在沉淀记忆后查看视图")
|
||||
: library?.graphFailed || library?.failed
|
||||
? text("记忆暂时无法读取")
|
||||
? raw
|
||||
? t("sdk.raw.loadFailed")
|
||||
: text("记忆暂时无法读取")
|
||||
: library?.graphLoading
|
||||
? text("正在读取记忆地图…")
|
||||
: library?.page &&
|
||||
(library.query || library.source || groupKey)
|
||||
? text("当前筛选没有可显示的记忆")
|
||||
? raw
|
||||
? t("sdk.raw.found", { count: 0 })
|
||||
: text("当前筛选没有可显示的记忆")
|
||||
: !loaded
|
||||
? text("正在读取记忆…")
|
||||
? raw
|
||||
? t("sdk.raw.recent")
|
||||
: text("正在读取记忆…")
|
||||
: active.length
|
||||
? text("当前筛选没有可显示的记忆")
|
||||
: arrival.importedMessages > 0
|
||||
? text("已保存的对话还未生成记忆")
|
||||
: text("还没有可显示的记忆")}
|
||||
? raw
|
||||
? t("sdk.raw.found", { count: 0 })
|
||||
: text("当前筛选没有可显示的记忆")
|
||||
: raw
|
||||
? t("sdk.raw.empty")
|
||||
: arrival.importedMessages > 0
|
||||
? text("已保存的对话还未生成记忆")
|
||||
: text("还没有可显示的记忆")}
|
||||
</p>
|
||||
{library?.graphFailed ? (
|
||||
<R6Button onClick={library.onRetry}>
|
||||
@@ -1313,13 +1392,18 @@ export function MemoryMap(props: Props) {
|
||||
>
|
||||
<header className="map-inspector-heading">
|
||||
<h2>
|
||||
{inspectorDetail
|
||||
? text("记忆详情")
|
||||
: selectedSource
|
||||
{raw
|
||||
? t("memory:原始对话")
|
||||
: inspectorDetail
|
||||
? text("记忆详情")
|
||||
: visibleGroupNode
|
||||
? text("关联节点")
|
||||
: text("记忆详情")}
|
||||
: selectedSource
|
||||
? text("记忆详情")
|
||||
: visibleGroupNode
|
||||
? visibleGroupNode.node_type === "topic" ||
|
||||
visibleGroupNode.node_type === "schema"
|
||||
? visibleGroupNode.label
|
||||
: text("关联节点")
|
||||
: text("记忆详情")}
|
||||
</h2>
|
||||
{(activeId || visibleGroupNode) && (
|
||||
<button
|
||||
@@ -1339,7 +1423,9 @@ export function MemoryMap(props: Props) {
|
||||
{library?.failed &&
|
||||
library.source === selectedSource.source_id ? (
|
||||
<>
|
||||
<p role="alert">{text("记忆暂时无法读取")}</p>
|
||||
<p role="alert">
|
||||
{raw ? t("sdk.raw.loadFailed") : text("记忆暂时无法读取")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
@@ -1356,10 +1442,12 @@ export function MemoryMap(props: Props) {
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
<p role="status">{text("正在读取记忆详情…")}</p>
|
||||
<p role="status">
|
||||
{raw ? t("sdk.raw.recent") : text("正在读取记忆详情…")}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>{text("还没有已生成的记忆")}</p>
|
||||
<p>{raw ? t("sdk.raw.empty") : text("还没有已生成的记忆")}</p>
|
||||
)}
|
||||
</div>
|
||||
) : props.simplified && inspectorDetail ? (
|
||||
@@ -1423,6 +1511,8 @@ export function MemoryMap(props: Props) {
|
||||
</div>
|
||||
<p role="status">{text("正在读取这条记忆…")}</p>
|
||||
</div>
|
||||
) : raw && props.rawRecord ? (
|
||||
<SelectedRawDetail record={props.rawRecord} sources={sources} />
|
||||
) : (
|
||||
<SelectedMemoryDetail
|
||||
detail={inspectorDetail}
|
||||
@@ -1452,6 +1542,7 @@ export function MemoryMap(props: Props) {
|
||||
onDraft={props.onDraft}
|
||||
onSave={props.onSave}
|
||||
onDelete={props.onDelete}
|
||||
onOpenMemory={pick}
|
||||
requestIds={props.requestIds}
|
||||
onClose={closeInspector}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const modes = [
|
||||
{ mode: "lite" as const, title: "Lite", description: "Raw conversations" },
|
||||
{ mode: "pro" as const, title: "Pro", description: "Extracted memories" },
|
||||
];
|
||||
|
||||
it("supports native radio keyboard selection and reports the chosen control", async () => {
|
||||
const changed = vi.fn();
|
||||
function Harness() {
|
||||
const [selected, setSelected] = useState<"lite" | "pro">("lite");
|
||||
return (
|
||||
<MemoryModeChoiceList
|
||||
aria-label="Memory mode"
|
||||
modes={modes}
|
||||
selected={selected}
|
||||
onChange={(mode, control) => {
|
||||
changed(mode, control);
|
||||
setSelected(mode as "lite" | "pro");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
const lite = screen.getByRole("radio", { name: /Lite/ });
|
||||
lite.focus();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
|
||||
const pro = screen.getByRole<HTMLInputElement>("radio", { name: /Pro/ });
|
||||
expect(pro.checked).toBe(true);
|
||||
expect(changed).toHaveBeenCalledWith("pro", pro);
|
||||
});
|
||||
|
||||
it("exposes current and recommended badges while respecting per-mode disabled state", () => {
|
||||
render(
|
||||
<MemoryModeChoiceList
|
||||
aria-label="Memory mode"
|
||||
modes={modes}
|
||||
current="lite"
|
||||
selected="lite"
|
||||
currentBadge="Current"
|
||||
recommended={{ mode: "pro", badge: "Recommended" }}
|
||||
disabled={(mode) => mode === "pro"}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Current")).toBeTruthy();
|
||||
expect(screen.getByText("Recommended")).toBeTruthy();
|
||||
expect(screen.getByRole("radio", { name: /Pro/ })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
});
|
||||
Loaded 100 of 217 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user