Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
675cc30e5a | ||
|
|
3511153f9f | ||
|
|
23facf75d5 | ||
|
|
384c7476ad | ||
|
|
71b8f9be24 | ||
|
|
d257188d09 | ||
|
|
bde0220373 | ||
|
|
a88d227cc0 | ||
|
|
059125a09f | ||
|
|
0d0cefb25e | ||
|
|
be8cf028ff | ||
|
|
ef5ff06590 | ||
|
|
7eb38aa6ad | ||
|
|
2624228f6f |
No files matched your search
@@ -4,6 +4,41 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- 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
|
||||
(`MemoryConfig.recall.intention_dedup_enabled`, default `true`;
|
||||
`recall.intention_dedup_threshold`, default `MEMORY_DEDUP_THRESHOLD` or
|
||||
`0.95`). Env defaults: `MEMORY_INTENTION_DEDUP_ENABLED`,
|
||||
`MEMORY_INTENTION_DEDUP_THRESHOLD`. When on, behaviour is unchanged
|
||||
(duplicates found at read time are still deleted); when off, no intention
|
||||
dedup runs, at read time or within a write batch, and nothing is hidden,
|
||||
dropped or deleted. The threshold applies to read-time dedup only. Refs #241.
|
||||
|
||||
## MemHub 1.0.6 — 2026-11-02
|
||||
|
||||
Build 37, on `hy-memory==2.0.11`. Linux x64/arm64 Runtime and macOS arm64
|
||||
|
||||
@@ -182,9 +182,29 @@ SDK 不设置 Rerank Search 并发上限。开启后模型在 `HyMemoryClient`
|
||||
| `MEMORY_RECALL_STRENGTH_TAU` | `180` | 衰减时间常数,天 |
|
||||
| `MEMORY_RECALL_ACCESS_TRACKING` | `true` | Strength 开启时是否回写访问信息 |
|
||||
| `MEMORY_ENTITY_STORE_ENABLED` | `false` | 是否维护独立 entity collection;当前 Reader 不直接读取 |
|
||||
| `MEMORY_INTENTION_DEDUP_ENABLED` | `true` | 是否对 L7 Intention 判重(读侧和写入侧批内);见下文 |
|
||||
| `MEMORY_INTENTION_DEDUP_THRESHOLD` | `MEMORY_DEDUP_THRESHOLD`,再缺省为 `0.95` | Intention 读侧判重的 cosine 阈值 |
|
||||
|
||||
旧数据可调用当前公开方法 `client.build_entity_store(user_id)` 补建 Entity Store。
|
||||
|
||||
Intention 判重:开启时(默认,与以往行为一致),Search 召回的 L7 Intention 中,
|
||||
同 `user_id` + 同 `agent_id` + `memory_at` 同一天 + `valid_until` 相同、且 cosine ≥ 阈值的
|
||||
条目视为复本,只返回最早写入的一条,其余在后台从向量库**删除**。判重不区分内容中的项目、
|
||||
对象等语义差异,内容高度相似但对象不同的意图可能被误删;写入时同一批抽取结果也会做一次
|
||||
批内判重。关闭后这两处都不判重,不隐藏、不丢弃、也不删除意图。两项对应
|
||||
`MemoryConfig.recall.intention_dedup_enabled` /
|
||||
`recall.intention_dedup_threshold`,按 client 生效,环境变量只提供默认值:
|
||||
|
||||
```python
|
||||
client = HyMemoryClient.from_config({
|
||||
"recall": {"intention_dedup_enabled": False},
|
||||
})
|
||||
```
|
||||
|
||||
`MEMORY_DEDUP_THRESHOLD` 同时被写入侧批内意图判重和 dedup 内核使用;
|
||||
`MEMORY_INTENTION_DEDUP_THRESHOLD` 只影响读侧 Intention 判重,写入侧批内判重的阈值
|
||||
仍取 `MEMORY_DEDUP_THRESHOLD`。
|
||||
|
||||
## 日志、Trace 与历史
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|
||||
@@ -432,6 +432,14 @@ class RecallConfig:
|
||||
retrieval_clauses_require_keyword: bool = None # env: MEMORY_RETRIEVAL_CLAUSES_REQUIRE_KEYWORD
|
||||
retrieval_clauses_min_keyword_score: float = None # normalized BM25 floor
|
||||
supplemental_propositions_enabled: bool = None # env: MEMORY_SUPPLEMENTAL_PROPOSITIONS_ENABLED
|
||||
# ── Intention(L7)判重 ──
|
||||
# 开启(默认):召回时同 user+agent+同天 memory_at+同 valid_until 且
|
||||
# cosine≥阈值 的意图视为复本,只返回最早一条,其余异步从 VDB 删除(历史行为)。
|
||||
# 关闭:读侧与写入侧批内都不判重——不隐藏、不丢弃、不删除任何意图。
|
||||
intention_dedup_enabled: bool = None # env: MEMORY_INTENTION_DEDUP_ENABLED
|
||||
# 读侧意图判重 cosine 阈值。env: MEMORY_INTENTION_DEDUP_THRESHOLD,
|
||||
# 未设置时回退到共享的 MEMORY_DEDUP_THRESHOLD(兼容旧部署),默认 0.95。
|
||||
intention_dedup_threshold: float = None
|
||||
|
||||
# 最小分数阈值
|
||||
min_score_threshold: float = 0.0
|
||||
@@ -750,6 +758,15 @@ class RecallConfig:
|
||||
self.supplemental_propositions_enabled = _get_env_bool(
|
||||
"MEMORY_SUPPLEMENTAL_PROPOSITIONS_ENABLED", False
|
||||
)
|
||||
if self.intention_dedup_enabled is None:
|
||||
self.intention_dedup_enabled = _get_env_bool(
|
||||
"MEMORY_INTENTION_DEDUP_ENABLED", True
|
||||
)
|
||||
if self.intention_dedup_threshold is None:
|
||||
self.intention_dedup_threshold = _get_env_float(
|
||||
"MEMORY_INTENTION_DEDUP_THRESHOLD",
|
||||
_get_env_float("MEMORY_DEDUP_THRESHOLD", 0.95),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -1384,6 +1401,8 @@ class MemoryConfig:
|
||||
"supplemental_propositions_enabled": (
|
||||
self.recall.supplemental_propositions_enabled
|
||||
),
|
||||
"intention_dedup_enabled": self.recall.intention_dedup_enabled,
|
||||
"intention_dedup_threshold": self.recall.intention_dedup_threshold,
|
||||
},
|
||||
"pipeline": {
|
||||
"default_version": self.pipeline.default_version,
|
||||
|
||||
@@ -15,6 +15,9 @@ HY Memory - Intention (L7) 召回 + 惰性过期转换 + 复本判定
|
||||
→ 视为同一个意图的复本(extractor 复读机 / 多 session 重复表达)。
|
||||
保留组里 `gmt_created` 最早的(老的先来先得,稳定 id);
|
||||
- **异步删库**:其余复本 fire-and-forget 从 VDB 删除,不影响本次调用 RT。
|
||||
- **开关**:判重受 `MemoryConfig.recall.intention_dedup_enabled`(默认开)与
|
||||
`recall.intention_dedup_threshold` 控制,由 reader 按 client 配置传入。
|
||||
关闭时完全跳过判重:不隐藏、不删除任何意图(写入侧批内判重同样跳过)。
|
||||
|
||||
Q: 为什么要看 memory_at 和 valid_until?
|
||||
A: 意图是 point-in-time 信号。同一句"我周五要开会",如果一条 memory_at 在
|
||||
@@ -38,8 +41,14 @@ from ...models.memory import MemoryLayer, MemoryStatus
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_dedup_threshold() -> float:
|
||||
"""去重 cosine 阈值:沿用 dedup 内核的 MEMORY_DEDUP_THRESHOLD,默认 0.95。"""
|
||||
def _get_dedup_threshold(configured: Optional[float] = None) -> float:
|
||||
"""去重 cosine 阈值:优先用 client 配置;否则(或配置非法)沿用 dedup 内核的
|
||||
MEMORY_DEDUP_THRESHOLD,默认 0.95。"""
|
||||
try:
|
||||
if configured is not None:
|
||||
return float(configured)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(f"[intention] invalid dedup threshold: {configured!r}")
|
||||
try:
|
||||
return float(os.environ.get("MEMORY_DEDUP_THRESHOLD", 0.95))
|
||||
except (TypeError, ValueError):
|
||||
@@ -244,6 +253,8 @@ async def recall_intentions(
|
||||
stats_out: Optional[Dict[str, Any]] = None,
|
||||
include_sources: Optional[List[str]] = None,
|
||||
exclude_sources: Optional[List[str]] = None,
|
||||
dedup_enabled: bool = True,
|
||||
dedup_threshold: Optional[float] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
召回未过期的 L7_INTENTION 节点(VDB),惰性过期转 L2_FACT,判重并异步删复本。
|
||||
@@ -260,6 +271,11 @@ async def recall_intentions(
|
||||
now: 当前时间(默认 datetime.now(),测试可注入)
|
||||
stats_out: 可选 dict,本函数把分段耗时与数量**原地回填**进去,供调用方落
|
||||
READ_INTENTION pipeline log。传 None 则完全不统计(零开销)。
|
||||
dedup_enabled: 是否执行读侧判重(及复本异步删库)。False 时跳过判重,
|
||||
所有未过期意图原样返回,不删除任何节点。默认 True(历史行为)。
|
||||
dedup_threshold: 判重 cosine 阈值。None 时回退到 env
|
||||
MEMORY_DEDUP_THRESHOLD(默认 0.95),兼容直接调用本函数的旧代码;
|
||||
reader 总是传入 `recall.intention_dedup_threshold`。
|
||||
|
||||
Returns:
|
||||
[{"node_id", "score", "node", "layer": "l7_intention", "source": "vdb_intention"}, ...]
|
||||
@@ -272,7 +288,7 @@ async def recall_intentions(
|
||||
埋点字段(回填进 stats_out):
|
||||
耗时(ms): vdb_search_ms / expire_ms / backfill_embed_ms / dedup_ms / total_ms
|
||||
数量: fetched / expired / survivors / dedup_dropped / returned
|
||||
配置: fetch_limit / out_limit / dedup_threshold
|
||||
配置: fetch_limit / out_limit / dedup_enabled / dedup_threshold
|
||||
说明: expire_ms、backfill_embed_ms 只有真的发生了对应操作才非 0,
|
||||
借此可判断慢在 VDB 检索、逐条过期转换、补拉向量还是判重计算。
|
||||
"""
|
||||
@@ -358,7 +374,7 @@ async def recall_intentions(
|
||||
_t = time.perf_counter()
|
||||
_backfilled = 0
|
||||
_missing_cnt = 0
|
||||
if survivors:
|
||||
if survivors and dedup_enabled: # 补拉只为判重;关闭时无需这次 VDB 读
|
||||
missing_ids = [
|
||||
h.get("node_id") for h in survivors
|
||||
if h.get("node_id") and not getattr(h.get("node"), "embedding", None)
|
||||
@@ -391,18 +407,23 @@ async def recall_intentions(
|
||||
)
|
||||
|
||||
# 2. 判重:同 user + 同天 memory_at + valid_until 同日 + cosine≥threshold
|
||||
# dedup_enabled=False → 完全跳过:不隐藏、不删除。
|
||||
_t = time.perf_counter()
|
||||
threshold = _get_dedup_threshold()
|
||||
try:
|
||||
kept, delete_ids = _partition_duplicates(survivors, threshold)
|
||||
except Exception as e:
|
||||
# 任何 dedup 异常 → 降级为不去重(返回全部 survivors 截断 limit)
|
||||
logger.warning(f"[intention] dedup partition failed, degrade to raw: {e}")
|
||||
threshold = _get_dedup_threshold(dedup_threshold)
|
||||
if dedup_enabled:
|
||||
try:
|
||||
kept, delete_ids = _partition_duplicates(survivors, threshold)
|
||||
except Exception as e:
|
||||
# 任何 dedup 异常 → 降级为不去重(返回全部 survivors 截断 limit)
|
||||
logger.warning(f"[intention] dedup partition failed, degrade to raw: {e}")
|
||||
kept, delete_ids = survivors, []
|
||||
else:
|
||||
kept, delete_ids = survivors, []
|
||||
# 纯 CPU(cosine 两两比较);survivors 多时可能显著
|
||||
_stat(
|
||||
dedup_ms=round((time.perf_counter() - _t) * 1000, 2),
|
||||
dedup_dropped=len(delete_ids),
|
||||
dedup_enabled=bool(dedup_enabled),
|
||||
dedup_threshold=threshold,
|
||||
)
|
||||
|
||||
|
||||
@@ -1856,6 +1856,12 @@ class BaseReadPipeline(ReadPipeline):
|
||||
stats_out=_intent_stats,
|
||||
include_sources=request.include_sources,
|
||||
exclude_sources=request.exclude_sources,
|
||||
dedup_enabled=bool(getattr(
|
||||
self.config.recall, "intention_dedup_enabled", True
|
||||
)),
|
||||
dedup_threshold=getattr(
|
||||
self.config.recall, "intention_dedup_threshold", None
|
||||
),
|
||||
)
|
||||
await trace_log.log_intention(
|
||||
stats=_intent_stats,
|
||||
|
||||
@@ -635,6 +635,7 @@ class MemoryWriter(WritePipeline):
|
||||
不查 VDB、不做"新写入 vs 库里已有"的对比,避免给 VDB 增加读压力。
|
||||
库内存量污染由 recall 侧异步删除逐步清理。best-effort:任何异常都
|
||||
回退到"不判重、全写入",绝不影响 upsert 主流程。
|
||||
`recall.intention_dedup_enabled=False` 时跳过批内判重,全部写入。
|
||||
"""
|
||||
if not intentions:
|
||||
return []
|
||||
@@ -660,7 +661,11 @@ class MemoryWriter(WritePipeline):
|
||||
# ============ 批内 dedup(不查 VDB,只对本批 intentions 互斥判重) ============
|
||||
keep_mask: List[bool] = [True] * len(intentions)
|
||||
try:
|
||||
dropped = self._intention_batch_dedup(intentions, embeddings)
|
||||
dropped = (
|
||||
self._intention_batch_dedup(intentions, embeddings)
|
||||
if getattr(self.config.recall, "intention_dedup_enabled", True)
|
||||
else []
|
||||
)
|
||||
for i in dropped:
|
||||
if 0 <= i < len(keep_mask):
|
||||
keep_mask[i] = False
|
||||
|
||||
@@ -209,6 +209,7 @@ class JsonlSourceAdapter:
|
||||
file_predicate: Callable[[AuthorizedSourceFile], bool] | None = None,
|
||||
suffixes: frozenset[str] = frozenset({".jsonl"}),
|
||||
header_selector: Callable[[dict[str, JsonValue]], bool] | None = None,
|
||||
header_gate: Callable[[dict[str, JsonValue] | None], bool] | None = None,
|
||||
) -> None:
|
||||
if descriptor.source_id != grant.source_id:
|
||||
raise ValueError("Descriptor and authorized root source IDs must match")
|
||||
@@ -219,6 +220,10 @@ class JsonlSourceAdapter:
|
||||
self.file_predicate = file_predicate or (lambda _: True)
|
||||
self.suffixes = suffixes
|
||||
self.header_selector = header_selector
|
||||
# False refuses the whole file before any record is read. It reports
|
||||
# and leaves no cursor, so a release that learns the format reads the
|
||||
# file from the top instead of finding it already behind a checkpoint.
|
||||
self.header_gate = header_gate
|
||||
self._header_cache: dict[str, tuple[str, dict[str, JsonValue] | None]] = {}
|
||||
self._history_listing_cache: SourceListing | None = None
|
||||
|
||||
@@ -371,6 +376,9 @@ class JsonlSourceAdapter:
|
||||
source_file, checkpoint, header = await asyncio.to_thread(
|
||||
self._prepare, listed, options
|
||||
)
|
||||
if self.header_gate is not None and not self.header_gate(header):
|
||||
yield ScanFailure(reason_code=ScanIssueCode.UNSUPPORTED_SCHEMA)
|
||||
continue
|
||||
_log.debug(
|
||||
"import_file_read source=%s file=%d/%d file_bytes=%d planned_bytes=%d",
|
||||
self.descriptor.source_id,
|
||||
@@ -574,6 +582,9 @@ class JsonlSourceAdapter:
|
||||
# resumed scan starts past the header, and the records after it need
|
||||
# it just as much as the first batch did.
|
||||
header = await _read_header(reader, source_file, self.header_selector)
|
||||
if self.header_gate is not None and not self.header_gate(header):
|
||||
yield ScanFailure(reason_code=ScanIssueCode.UNSUPPORTED_SCHEMA)
|
||||
continue
|
||||
last: SourceCursor = _start_cursor(source_file, options, checkpoint)
|
||||
async for raw in iterate_in_worker(
|
||||
partial(reader, source_file, checkpoint=checkpoint),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
from pydantic import JsonValue
|
||||
@@ -37,6 +38,19 @@ DEEPSEEK_HARNESS_DESCRIPTOR = SourceDescriptor(
|
||||
),
|
||||
)
|
||||
|
||||
#: One session is one file, and the name carries the format generation it was
|
||||
#: written in: `session.jsonl[.zstd]` for the first format, then
|
||||
#: `session.v3.jsonl.zstd` and `session.v4.jsonl.zstd`. The version segment is
|
||||
#: part of the published on-disk contract, so discovery must accept it — the
|
||||
#: earlier exact-name match imported none of the versioned files.
|
||||
SESSION_FILE = re.compile(r"^session(?:\.v[0-9]+)?\.jsonl(?:\.zstd)?$")
|
||||
|
||||
#: Session generations this parser has been verified against: the first format
|
||||
#: (header `version: 0`), v3 and v4. Discovery is intentionally wider than
|
||||
#: parsing: a future generation is found by pattern and then skipped from its
|
||||
#: header, so it is reported as unsupported instead of silently vanishing.
|
||||
SUPPORTED_SESSION_VERSIONS = frozenset({0, 3, 4})
|
||||
|
||||
|
||||
def create_deepseek_harness_adapter(
|
||||
grant: AuthorizedSourceRoot,
|
||||
@@ -49,25 +63,43 @@ def create_deepseek_harness_adapter(
|
||||
grant=grant,
|
||||
parser=parse_deepseek_harness_record,
|
||||
source_version=source_version,
|
||||
file_predicate=lambda source_file: (
|
||||
source_file.relative_path.name in {"session.jsonl", "session.jsonl.zstd"}
|
||||
),
|
||||
file_predicate=lambda source_file: bool(SESSION_FILE.match(source_file.relative_path.name)),
|
||||
suffixes=frozenset({".jsonl", ".zstd"}),
|
||||
header_selector=_is_session_header,
|
||||
header_gate=_supported_session,
|
||||
)
|
||||
|
||||
|
||||
def _is_session_header(record: dict[str, JsonValue]) -> bool:
|
||||
"""Harness writes the session's `cwd` once, in the first line only.
|
||||
|
||||
Verified against a session this machine produced: the header is
|
||||
`{"type":"session","version":0,"id":...,"cwd":"/private/tmp/dsh-verify"}`
|
||||
and no later record repeats it. Read per-record, `cwd` is therefore always
|
||||
absent and every turn imports with no workspace.
|
||||
Verified against sessions this machine produced: the header is
|
||||
`{"type":"session","version":0|4,"id":...,"cwd":...}` and no later record
|
||||
repeats it. Read per-record, `cwd` is therefore always absent and every turn
|
||||
imports with no workspace.
|
||||
"""
|
||||
return record.get("type") == "session"
|
||||
|
||||
|
||||
def _supported_session(header: dict[str, JsonValue] | None) -> bool:
|
||||
"""Whether the session's declared format generation is one we can read.
|
||||
|
||||
A missing header, or a header without `version`, is the first format — the
|
||||
earliest files this adapter already read. Any declared version outside
|
||||
`SUPPORTED_SESSION_VERSIONS` is refused rather than interpreted with today's
|
||||
field rules: a future format may change field semantics, and importing it
|
||||
wrongly is harder to undo than not importing it yet.
|
||||
"""
|
||||
if header is None:
|
||||
return True
|
||||
version = header.get("version")
|
||||
if version is None:
|
||||
return True
|
||||
if not isinstance(version, int) or isinstance(version, bool):
|
||||
return False
|
||||
return version in SUPPORTED_SESSION_VERSIONS
|
||||
|
||||
|
||||
def parse_deepseek_harness_record(
|
||||
record: dict[str, JsonValue],
|
||||
context: SourceRecordContext,
|
||||
@@ -80,6 +112,11 @@ def parse_deepseek_harness_record(
|
||||
)
|
||||
event_type = record.get("type")
|
||||
data = as_record(record.get("data"))
|
||||
if not _supported_session(context.header):
|
||||
return ParsedSkip(
|
||||
reason_code=ScanIssueCode.UNSUPPORTED_SCHEMA,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
if event_type == "session":
|
||||
return ParsedSkip(reason_code=ScanIssueCode.UNKNOWN_RECORD)
|
||||
if data is None:
|
||||
@@ -148,11 +185,18 @@ def _tool_message(
|
||||
"""`tool/call` carries name and arguments; `tool/result` a user-role message.
|
||||
|
||||
The same call also appears as a `tool-call` block in `assistant/message`;
|
||||
only the event is read, so it is stored once.
|
||||
only the event is read, so it is stored once. The first format and v3 wrap
|
||||
the result in `tool-result` blocks; v4 writes plain `text` blocks, so both
|
||||
shapes are read through the same clipping and credential-scrubbing path.
|
||||
"""
|
||||
message = as_record(data.get("message"))
|
||||
if record.get("type") == "tool/result" and message is not None:
|
||||
text = tool_blocks_evidence(message.get("content"), block_types=frozenset({"tool-result"}))
|
||||
if text is None:
|
||||
text = tool_blocks_evidence(
|
||||
[{"type": "tool-result", "content": message.get("content")}],
|
||||
block_types=frozenset({"tool-result"}),
|
||||
)
|
||||
message_id = first_string(message.get("id"), data.get("callId"))
|
||||
else:
|
||||
text = tool_evidence_text(data)
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
| `test_c1_cross_source_session_isolation.py` | `contract` | Same session name in two sources must stay two conversations. |
|
||||
| `test_c1_damage_per_source.py` | `contract` | Damage and unknown schema, per source: a bad record must not sink the import. |
|
||||
| `test_c1_deepseek_profiles.py` | `contract` | DeepSeek Harness has two profiles; only the headless session form is importable. |
|
||||
| `test_c1_deepseek_versioned_sessions.py` | `contract` | DeepSeek Harness versions its session file names; every generation must import. |
|
||||
| `test_c1_import_event_matrix.py` | `contract` | C1 owned matrix: existing history, new file, append, truncate/replace, restart, revoke. |
|
||||
| `test_c1_import_failure_injection.py` | `contract` | C1 failure injection: atomic receipt, disk-full, read-only source, rotation. |
|
||||
| `test_c1_import_instrumentation.py` | `contract` | Instrumented import accounting: bytes read, opens, hashes, jobs, duplicates. |
|
||||
@@ -318,4 +319,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. |
|
||||
|
||||
共 313 个测试文件。
|
||||
共 314 个测试文件。
|
||||
@@ -14,8 +14,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
|
||||
EXPECTED_TEST_FILE_COUNT = 313
|
||||
EXPECTED_TEST_FILES_SHA256 = "dcada5d5421767196a79e415dd47c2b6c6d67681972dbe81481b31867d318928"
|
||||
EXPECTED_TEST_FILE_COUNT = 314
|
||||
EXPECTED_TEST_FILES_SHA256 = "d745df3dfb964a8d2197c1ca3768728249d84ac4729305155ae1ef48428a19e4"
|
||||
|
||||
# Pure transformations, validation models, parity checks and state machines.
|
||||
# This list is intentionally small: uncertain files default to contract.
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""DeepSeek Harness versions its session file names; every generation must import.
|
||||
|
||||
The first format writes `<session>/session.jsonl[.zstd]`; v3 and v4 write
|
||||
`session.v3.jsonl.zstd` / `session.v4.jsonl.zstd`. Discovery used to match only
|
||||
the two unversioned names, so every v3/v4 session was invisible to the import
|
||||
job — `discovered 0` while the files sat on disk. These tests pin the file-name
|
||||
contract, the header gate that keeps an unknown future version out of the
|
||||
database, and the upgrade path where a library imported before the fix picks up
|
||||
the versioned files on its next scan.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import zstandard
|
||||
|
||||
from one_memory.imports.adapters import ScanFailure, ScanIssueCode
|
||||
from one_memory.imports.sources import deepseek_harness
|
||||
from one_memory.imports.sources.deepseek_harness import (
|
||||
DEEPSEEK_HARNESS_DESCRIPTOR,
|
||||
create_deepseek_harness_adapter,
|
||||
)
|
||||
from tests.test_secondary_source_adapters import (
|
||||
_cursors,
|
||||
_evidence,
|
||||
_evidence_text,
|
||||
_grant,
|
||||
_scan,
|
||||
_skip_codes,
|
||||
)
|
||||
|
||||
|
||||
def _write_session(directory: Path, name: str, records: list[dict[str, object]]) -> None:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
payload = "".join(f"{json.dumps(record)}\n" for record in records).encode()
|
||||
(directory / name).write_bytes(zstandard.ZstdCompressor().compress(payload))
|
||||
|
||||
|
||||
def _conversation(version: int | None, *, directory: str = "run-a") -> list[dict[str, object]]:
|
||||
header: dict[str, object] = {
|
||||
"type": "session",
|
||||
"version": version,
|
||||
"id": directory,
|
||||
"cwd": "/private/versioned-project",
|
||||
}
|
||||
return [
|
||||
header,
|
||||
{
|
||||
"type": "user/message",
|
||||
"seq": 1,
|
||||
"data": {
|
||||
"id": "human",
|
||||
"role": "user",
|
||||
"source": {"kind": "user"},
|
||||
"content": [{"type": "text", "text": "visible human"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "assistant/message",
|
||||
"seq": 2,
|
||||
"data": {
|
||||
"message": {
|
||||
"id": "assistant",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "visible assistant"}],
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _session_adapter(tmp_path: Path):
|
||||
root = tmp_path / "dsh"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root, create_deepseek_harness_adapter(
|
||||
_grant(DEEPSEEK_HARNESS_DESCRIPTOR.source_id, root, tmp_path)
|
||||
)
|
||||
|
||||
|
||||
def test_versioned_v4_session_file_is_discovered_and_imported(tmp_path: Path) -> None:
|
||||
root, adapter = _session_adapter(tmp_path)
|
||||
session = root / "run-v4"
|
||||
records = _conversation(4, directory="run-v4")
|
||||
records.append(
|
||||
{
|
||||
"type": "tool/result",
|
||||
"seq": 3,
|
||||
"time": 1793341068763,
|
||||
"data": {
|
||||
"callId": "call-1",
|
||||
"message": {"role": "tool", "content": [{"type": "text", "text": "tool output"}]},
|
||||
},
|
||||
}
|
||||
)
|
||||
_write_session(session, "session.v4.jsonl.zstd", records)
|
||||
# A rotated backup next to the live file must not be imported as a second copy.
|
||||
(session / "session.v4.jsonl.zstd.orig-20261027").write_bytes(
|
||||
zstandard.ZstdCompressor().compress(b'{"type":"session","version":4}\n')
|
||||
)
|
||||
|
||||
events = _scan(adapter)
|
||||
|
||||
assert _evidence_text(events) == ["visible human", "visible assistant", "tool output"]
|
||||
evidence = _evidence(events)
|
||||
assert {event.evidence.project for event in evidence} == {"versioned-project"}
|
||||
assert {event.evidence.conversation_id for event in evidence} == {"run-v4"}
|
||||
|
||||
|
||||
def test_versioned_v3_session_file_is_discovered_and_imported(tmp_path: Path) -> None:
|
||||
root, adapter = _session_adapter(tmp_path)
|
||||
_write_session(root / "run-v3", "session.v3.jsonl.zstd", _conversation(3))
|
||||
|
||||
events = _scan(adapter)
|
||||
|
||||
assert _evidence_text(events) == ["visible human", "visible assistant"]
|
||||
|
||||
|
||||
def _tool_result(content: list[dict[str, object]]) -> dict[str, object]:
|
||||
return {
|
||||
"type": "tool/result",
|
||||
"seq": 3,
|
||||
"data": {"callId": "call-1", "message": {"role": "tool", "content": content}},
|
||||
}
|
||||
|
||||
|
||||
def test_tool_output_imports_in_either_block_shape(tmp_path: Path) -> None:
|
||||
"""`tool/result` output written as `text` blocks used to be dropped.
|
||||
|
||||
Only `tool-result` blocks were read, so a session that writes plain `text`
|
||||
blocks lost every tool output. Both shapes must arrive, in any generation.
|
||||
"""
|
||||
root, adapter = _session_adapter(tmp_path)
|
||||
v3 = _conversation(3, directory="run-v3-text")
|
||||
v3.append(_tool_result([{"type": "text", "text": "v3 text output"}]))
|
||||
_write_session(root / "run-v3-text", "session.v3.jsonl.zstd", v3)
|
||||
v0 = _conversation(0, directory="run-v0-block")
|
||||
v0.append(_tool_result([{"type": "tool-result", "content": "v0 block output"}]))
|
||||
_write_session(root / "run-v0-block", "session.jsonl.zstd", v0)
|
||||
|
||||
texts = _evidence_text(_scan(adapter))
|
||||
|
||||
assert "v3 text output" in texts
|
||||
assert "v0 block output" in texts
|
||||
|
||||
|
||||
def test_text_tool_output_is_scrubbed_like_block_output(tmp_path: Path) -> None:
|
||||
root, adapter = _session_adapter(tmp_path)
|
||||
secret = "sk-" + "a" * 40
|
||||
records = _conversation(4, directory="run-secret")
|
||||
records.append(_tool_result([{"type": "text", "text": f"token {secret} " + "x" * 5000}]))
|
||||
_write_session(root / "run-secret", "session.v4.jsonl.zstd", records)
|
||||
|
||||
tool = _evidence_text(_scan(adapter))[-1]
|
||||
|
||||
assert secret not in tool
|
||||
assert len(tool) < 2000
|
||||
|
||||
|
||||
def test_session_file_names_outside_the_contract_are_not_imported(tmp_path: Path) -> None:
|
||||
root, adapter = _session_adapter(tmp_path)
|
||||
for name in (
|
||||
"session.v4.jsonl.zstd.orig-20261027",
|
||||
"session.vx.jsonl.zstd",
|
||||
"other.v4.jsonl.zstd",
|
||||
"session.v4.json",
|
||||
):
|
||||
_write_session(root / name.replace(".", "-"), name, _conversation(4))
|
||||
|
||||
assert _evidence(_scan(adapter)) == []
|
||||
|
||||
|
||||
def test_unknown_future_session_version_imports_nothing(tmp_path: Path) -> None:
|
||||
root, adapter = _session_adapter(tmp_path)
|
||||
records = _conversation(5)
|
||||
records.append(
|
||||
{
|
||||
"type": "user/message",
|
||||
"seq": 9,
|
||||
"data": {
|
||||
"id": "future",
|
||||
"role": "user",
|
||||
"source": {"kind": "user"},
|
||||
"content": [{"type": "text", "text": "future generation payload"}],
|
||||
},
|
||||
}
|
||||
)
|
||||
_write_session(root / "run-v5", "session.v5.jsonl.zstd", records)
|
||||
|
||||
events = _scan(adapter)
|
||||
|
||||
assert _evidence(events) == []
|
||||
assert ScanIssueCode.UNSUPPORTED_SCHEMA in _skip_codes(events)
|
||||
|
||||
|
||||
def test_unknown_session_version_is_reported_and_read_once_supported(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Not importing a future format yet must stay "yet".
|
||||
|
||||
A cursor left behind by the refused scan would put the whole session behind
|
||||
the checkpoint, so the release that learns the format would read nothing.
|
||||
"""
|
||||
root, adapter = _session_adapter(tmp_path)
|
||||
_write_session(root / "run-v5", "session.v5.jsonl.zstd", _conversation(5))
|
||||
|
||||
refused = _scan(adapter)
|
||||
|
||||
assert _cursors(refused) == {}
|
||||
assert [event.reason_code for event in refused if isinstance(event, ScanFailure)] == [
|
||||
ScanIssueCode.UNSUPPORTED_SCHEMA
|
||||
]
|
||||
|
||||
monkeypatch.setattr(deepseek_harness, "SUPPORTED_SESSION_VERSIONS", frozenset({0, 3, 4, 5}))
|
||||
_, upgraded = _session_adapter(tmp_path)
|
||||
|
||||
assert _evidence_text(_scan(upgraded, cursors=_cursors(refused))) == [
|
||||
"visible human",
|
||||
"visible assistant",
|
||||
]
|
||||
|
||||
|
||||
def test_upgrade_scan_rediscovers_versioned_files_already_on_disk(tmp_path: Path) -> None:
|
||||
"""A library imported before this fix must pick up the files on its next scan.
|
||||
|
||||
The first scan sees only the unversioned session that the old predicate
|
||||
matched; after the upgrade, the versioned file next to it is discovered by
|
||||
the next scan while the already imported session stays deduplicated by the
|
||||
cursor it left behind.
|
||||
"""
|
||||
root, adapter = _session_adapter(tmp_path)
|
||||
old = root / "run-old"
|
||||
_write_session(old, "session.jsonl.zstd", _conversation(0, directory="run-old"))
|
||||
first_scan = _scan(adapter)
|
||||
assert _evidence_text(first_scan) == ["visible human", "visible assistant"]
|
||||
assert _evidence(first_scan)[0].evidence.conversation_id == "run-old"
|
||||
|
||||
versioned = root / "run-v4"
|
||||
_write_session(versioned, "session.v4.jsonl.zstd", _conversation(4, directory="run-v4"))
|
||||
cursors = _cursors(first_scan)
|
||||
assert cursors # the old session left a cursor behind
|
||||
|
||||
upgrade_scan = _scan(adapter, cursors=cursors)
|
||||
|
||||
assert _evidence_text(upgrade_scan) == ["visible human", "visible assistant"]
|
||||
assert {event.evidence.conversation_id for event in _evidence(upgrade_scan)} == {"run-v4"}
|
||||
@@ -193,7 +193,7 @@ def _qwenwork() -> Harness:
|
||||
|
||||
def _deepseek() -> Harness:
|
||||
def write(path: Path, turns: list[Turn]) -> None:
|
||||
# deepseek only accepts a file literally named session.jsonl, and nests
|
||||
# deepseek only accepts files named session[.vN].jsonl[.zstd], and nests
|
||||
# the turn under "data".
|
||||
lines: list[dict] = [{"type": "session", "version": 0, "id": "s1", "cwd": "/w"}]
|
||||
for index, (role, text, _session) in enumerate(turns):
|
||||
@@ -225,7 +225,7 @@ def _deepseek() -> Harness:
|
||||
|
||||
from one_memory.imports.sources.deepseek_harness import create_deepseek_harness_adapter
|
||||
|
||||
# deepseek accepts only a file literally named session.jsonl.
|
||||
# deepseek accepts only session[.vN].jsonl[.zstd]; the first format is used here.
|
||||
return Harness(
|
||||
"deepseek-harness",
|
||||
write,
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
Tray,
|
||||
} from "electron";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { installBackgroundTray } from "./background-tray";
|
||||
import { installBackgroundTray, type RuntimeHosting } from "./background-tray";
|
||||
import type { LoginPreference } from "./login-preference";
|
||||
|
||||
async function flush() {
|
||||
@@ -27,6 +27,7 @@ function fixture(
|
||||
status: "disabled",
|
||||
};
|
||||
let changed: (() => void) | undefined;
|
||||
let hosting: RuntimeHosting | Error = "background-service";
|
||||
const languageSnapshot: LanguageSnapshot = {
|
||||
preference: "zh-Hans",
|
||||
locale: "zh-Hans",
|
||||
@@ -50,6 +51,10 @@ function fixture(
|
||||
};
|
||||
return state;
|
||||
});
|
||||
const readRuntimeHosting = vi.fn(async () => {
|
||||
if (hosting instanceof Error) throw hosting;
|
||||
return hosting;
|
||||
});
|
||||
const open = vi.fn();
|
||||
const reportError = vi.fn();
|
||||
const install = () =>
|
||||
@@ -77,6 +82,7 @@ function fixture(
|
||||
reportError,
|
||||
readLoginPreference,
|
||||
setLoginPreference,
|
||||
readRuntimeHosting,
|
||||
...(trial
|
||||
? { allowLoginStartup: false, productName: "One Memory Trial" }
|
||||
: {}),
|
||||
@@ -91,6 +97,7 @@ function fixture(
|
||||
menus,
|
||||
readLoginPreference,
|
||||
setLoginPreference,
|
||||
readRuntimeHosting,
|
||||
open,
|
||||
reportError,
|
||||
install,
|
||||
@@ -103,6 +110,9 @@ function fixture(
|
||||
setState(value: LoginPreference) {
|
||||
state = value;
|
||||
},
|
||||
setHosting(value: RuntimeHosting | Error) {
|
||||
hosting = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,6 +189,50 @@ describe("native macOS background tray", () => {
|
||||
expect(f.tray.eventNames()).toEqual([]);
|
||||
});
|
||||
|
||||
it("says quitting stops the memory service when the App hosts it", async () => {
|
||||
const f = fixture();
|
||||
f.setHosting("app-hosted");
|
||||
f.install();
|
||||
await flush();
|
||||
expect(f.menu()[3]?.label).toBe("退出 MemHub(同时停止记忆服务)");
|
||||
f.changeLanguage("es");
|
||||
await flush();
|
||||
expect(f.menu()[3]?.label).toBe(
|
||||
"Salir de MemHub (también detiene el servicio de memoria)",
|
||||
);
|
||||
});
|
||||
|
||||
it("re-reads who hosts the Runtime every time the menu opens", async () => {
|
||||
const f = fixture();
|
||||
f.install();
|
||||
await flush();
|
||||
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
|
||||
|
||||
// The user turned the background service off in Settings.
|
||||
f.setHosting("app-hosted");
|
||||
f.tray.emit("mouse-down");
|
||||
await flush();
|
||||
expect(f.menu()[3]?.label).toContain("同时停止记忆服务");
|
||||
|
||||
f.setHosting("background-service");
|
||||
f.tray.emit("right-click");
|
||||
await flush();
|
||||
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
|
||||
expect(f.readRuntimeHosting).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("never claims quit stops the service when the state is unknown", async () => {
|
||||
const f = fixture();
|
||||
f.setHosting(new Error("launchctl timed out"));
|
||||
f.install();
|
||||
await flush();
|
||||
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
|
||||
f.setHosting("unknown");
|
||||
f.tray.emit("mouse-down");
|
||||
await flush();
|
||||
expect(f.menu()[3]?.label).toContain("后台服务继续运行");
|
||||
});
|
||||
|
||||
it("blocks login changes for trial, development and non-macOS builds", async () => {
|
||||
for (const f of [
|
||||
fixture(true, "darwin", true),
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
import type { LoginPreference } from "./login-preference";
|
||||
import type { NativeLanguageSource } from "./native-language";
|
||||
|
||||
export type RuntimeHosting = "background-service" | "app-hosted" | "unknown";
|
||||
|
||||
export const LOGIN_SERVICE_NAME = "com.tencent.onememory.runtime.plist";
|
||||
|
||||
interface TrayOptions {
|
||||
@@ -24,6 +26,9 @@ interface TrayOptions {
|
||||
reportError(message: string): void;
|
||||
readLoginPreference(): Promise<LoginPreference>;
|
||||
setLoginPreference(enabled: boolean): Promise<LoginPreference>;
|
||||
/** Who keeps the Runtime alive, re-read on every menu refresh: the
|
||||
* background service (quit leaves it running) or this App (quit stops it). */
|
||||
readRuntimeHosting?(): Promise<RuntimeHosting>;
|
||||
allowLoginStartup?: boolean;
|
||||
productName?: string;
|
||||
language?: NativeLanguageSource;
|
||||
@@ -67,6 +72,13 @@ export function installBackgroundTray(options: TrayOptions): () => void {
|
||||
} else {
|
||||
label += t("nativeDevelopmentUnavailable");
|
||||
}
|
||||
let hosting: RuntimeHosting = "unknown";
|
||||
try {
|
||||
hosting = (await options.readRuntimeHosting?.()) ?? "unknown";
|
||||
} catch {
|
||||
hosting = "unknown";
|
||||
}
|
||||
if (disposed || current !== generation) return;
|
||||
tray.setContextMenu(
|
||||
options.buildMenu([
|
||||
{ label: t("nativeOpen"), click: options.open },
|
||||
@@ -92,7 +104,10 @@ export function installBackgroundTray(options: TrayOptions): () => void {
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: t("nativeQuit"),
|
||||
// Unknown keeps the plain label: quit then stops nothing.
|
||||
label: t(
|
||||
hosting === "app-hosted" ? "nativeQuitStopsService" : "nativeQuit",
|
||||
),
|
||||
click: () => {
|
||||
if (!disposed) app.quit();
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
claimRuntimeServiceForApp,
|
||||
claimRuntimeServiceForCli,
|
||||
enableRuntimeService,
|
||||
type RuntimeServiceState,
|
||||
runtimeServiceEnableMarkerPath,
|
||||
} from "@one-memory/runtime-control";
|
||||
import { expect, it } from "vitest";
|
||||
@@ -98,3 +99,25 @@ it("rejects unsupported and unconfirmed writes", async () => {
|
||||
),
|
||||
).rejects.toThrow("login_preference_unsupported");
|
||||
});
|
||||
|
||||
it("reads a launchctl-disabled service as off and not switchable, asked fresh", async () => {
|
||||
const dataDir = await appOwned();
|
||||
let state: RuntimeServiceState = {
|
||||
state: "disabled",
|
||||
reason: "service-disabled-by-launchctl",
|
||||
jobPid: null,
|
||||
};
|
||||
const serviceState = async () => state;
|
||||
|
||||
await expect(
|
||||
readLoginPreference(dataDir, "darwin", true, serviceState),
|
||||
).resolves.toEqual({
|
||||
supported: false,
|
||||
enabled: false,
|
||||
status: "disabled-by-system",
|
||||
});
|
||||
state = { state: "enabled", reason: "service-enabled", jobPid: 4321 };
|
||||
await expect(
|
||||
readLoginPreference(dataDir, "darwin", true, serviceState),
|
||||
).resolves.toEqual({ supported: true, enabled: true, status: "enabled" });
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
disableRuntimeService,
|
||||
enableRuntimeService,
|
||||
type RuntimeServiceState,
|
||||
readRuntimeServiceOwner,
|
||||
runtimeServiceEnabled,
|
||||
setAppRuntimeStartAtLogin,
|
||||
@@ -12,10 +13,16 @@ export interface LoginPreference {
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** "Launch at login" is the background service switch: it writes the enable
|
||||
* marker the LaunchAgent's `KeepAlive` waits on. On it means the Runtime
|
||||
* outlives the App and quitting leaves it running; off means quitting stops
|
||||
* it. `serviceState`, when given, is asked fresh so a `launchctl disable`
|
||||
* override (which the switch cannot undo) reads as off, not as on. */
|
||||
export async function readLoginPreference(
|
||||
dataDir: string,
|
||||
platform: NodeJS.Platform,
|
||||
supported: boolean,
|
||||
serviceState?: () => Promise<RuntimeServiceState>,
|
||||
): Promise<LoginPreference> {
|
||||
if (platform !== "darwin" || !supported) {
|
||||
return { supported: false, enabled: false, status: "unsupported" };
|
||||
@@ -28,6 +35,12 @@ export async function readLoginPreference(
|
||||
if (owner?.owner !== "app") {
|
||||
return { supported: false, enabled: false, status: "not-installed" };
|
||||
}
|
||||
if (
|
||||
serviceState &&
|
||||
(await serviceState()).reason === "service-disabled-by-launchctl"
|
||||
) {
|
||||
return { supported: false, enabled: false, status: "disabled-by-system" };
|
||||
}
|
||||
return {
|
||||
supported: true,
|
||||
enabled: owner.start_at_login && (await runtimeServiceEnabled(dataDir)),
|
||||
@@ -46,6 +59,7 @@ export async function setLoginPreference(
|
||||
platform: NodeJS.Platform,
|
||||
input: unknown,
|
||||
supported: boolean,
|
||||
serviceState?: () => Promise<RuntimeServiceState>,
|
||||
): Promise<LoginPreference> {
|
||||
if (
|
||||
!input ||
|
||||
@@ -63,5 +77,5 @@ export async function setLoginPreference(
|
||||
await setAppRuntimeStartAtLogin(dataDir, input.openAtLogin);
|
||||
if (input.openAtLogin) await enableRuntimeService(dataDir);
|
||||
else await disableRuntimeService(dataDir);
|
||||
return readLoginPreference(dataDir, platform, supported);
|
||||
return readLoginPreference(dataDir, platform, supported, serviceState);
|
||||
}
|
||||
+140
-21
@@ -23,7 +23,9 @@ import {
|
||||
type HostPermissionClient,
|
||||
installPackagedRuntime,
|
||||
installUserService,
|
||||
queryRuntimeServiceState,
|
||||
RecallBrokerSupervisor,
|
||||
type RuntimeServiceState,
|
||||
RuntimeSupervisor,
|
||||
readInstalledRuntimeSource,
|
||||
readPublishedCorePort,
|
||||
@@ -110,6 +112,7 @@ import { showMigrationFailureDialog } from "./migration-failure-dialog";
|
||||
import { isAllowedNavigation } from "./navigation-policy";
|
||||
import { installDenyAllPermissionPolicy } from "./permissions";
|
||||
import { isHostPresent, recallHosts } from "./recall-hosts";
|
||||
import { reconcileRunningRuntime } from "./runtime-adoption";
|
||||
import { shutdownRuntime } from "./runtime-shutdown";
|
||||
import { createSourceChangeHandler } from "./source-change-handler";
|
||||
import { SourceDirectoryPicker } from "./source-directory-picker";
|
||||
@@ -140,7 +143,12 @@ let runtimeSupervisor: RuntimeSupervisor | null = null;
|
||||
// Where the Runtime keeps its receipt. Stopping it on a full quit needs this,
|
||||
// and `stop` runs outside `initialize`, where the directory is chosen.
|
||||
let runtimeDataDir: string | null = null;
|
||||
let preserveManagedRuntimeOnQuit = false;
|
||||
// Whether quitting stops the Runtime is asked of the service manager at quit
|
||||
// time (`currentRuntimeServiceState`), never remembered from startup. The one
|
||||
// startup fact kept is a Runtime whose build could not be read: this App never
|
||||
// stops one it could not compare with its own.
|
||||
let preserveRuntimeOnQuitReason: string | null = null;
|
||||
let runtimeServiceManagedByApp = false;
|
||||
let disposeTray: (() => void) | null = null;
|
||||
let disposeApplicationMenu: (() => void) | null = null;
|
||||
let languagePreferences: LanguagePreferences | null = null;
|
||||
@@ -247,6 +255,25 @@ if (trialProfile) {
|
||||
app.setPath("userData", hourlyUserDataDir);
|
||||
}
|
||||
|
||||
/** Ask the service manager, now, whether the background service for this data
|
||||
* dir is on. Never cached: quit and the tray each re-read it, so a switch made
|
||||
* in Settings, by `memhub service`, or by `launchctl` counts immediately. */
|
||||
function currentRuntimeServiceState(): Promise<RuntimeServiceState> {
|
||||
if (!runtimeDataDir) {
|
||||
return Promise.resolve({
|
||||
state: "unknown",
|
||||
reason: "service-state-query-failed",
|
||||
jobPid: null,
|
||||
});
|
||||
}
|
||||
return queryRuntimeServiceState({
|
||||
dataDir: runtimeDataDir,
|
||||
home: app.getPath("home"),
|
||||
platform: process.platform,
|
||||
uid: process.getuid?.(),
|
||||
});
|
||||
}
|
||||
|
||||
function prepareProtocol(): void {
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
@@ -498,6 +525,7 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
!preview &&
|
||||
(hostIntegrationEnabled || desktopTestIsolation !== null) &&
|
||||
installedApplication;
|
||||
runtimeServiceManagedByApp = appManagedRuntime;
|
||||
const runningPackagedHost =
|
||||
app.isPackaged && !isolatedHourly
|
||||
? await resolveRunningPackagedHostPaths({
|
||||
@@ -594,9 +622,11 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
throw new Error("host_permission_store_unavailable");
|
||||
}
|
||||
if (isQuitting()) return;
|
||||
const hosts = (await recallHosts(app.getPath("home"))).filter(
|
||||
(host) => host.hostId !== "openclaw",
|
||||
);
|
||||
const scanHosts = async () =>
|
||||
(await recallHosts(app.getPath("home"))).filter(
|
||||
(host) => host.hostId !== "openclaw",
|
||||
);
|
||||
const hosts = await scanHosts();
|
||||
readDiagnosticSummary = async () => {
|
||||
const core = coreSupervisor;
|
||||
if (!core) return {};
|
||||
@@ -851,14 +881,25 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
);
|
||||
ipcMain.handle("one-memory:get-login-preference", (event) => {
|
||||
assertAllowedRenderer(event.senderFrame?.url);
|
||||
return readLoginPreference(dataDir, process.platform, appManagedRuntime);
|
||||
return readLoginPreference(
|
||||
dataDir,
|
||||
process.platform,
|
||||
appManagedRuntime,
|
||||
currentRuntimeServiceState,
|
||||
);
|
||||
});
|
||||
ipcMain.handle("one-memory:set-login-preference", (event, input: unknown) => {
|
||||
assertAllowedRenderer(event.senderFrame?.url);
|
||||
return observeDesktopAction(
|
||||
"login_preference",
|
||||
() =>
|
||||
setLoginPreference(dataDir, process.platform, input, appManagedRuntime),
|
||||
setLoginPreference(
|
||||
dataDir,
|
||||
process.platform,
|
||||
input,
|
||||
appManagedRuntime,
|
||||
currentRuntimeServiceState,
|
||||
),
|
||||
{ action: "set" },
|
||||
);
|
||||
});
|
||||
@@ -876,10 +917,14 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
});
|
||||
ipcMain.handle("one-memory:recall-hosts", async (event) => {
|
||||
assertAllowedRenderer(event.senderFrame?.url);
|
||||
// A Harness profile can appear at any time, and which ones exist is only
|
||||
// knowable from the disk. In place, so `requireHost` sees it too. One
|
||||
// directory read per list, next to an IPC round trip per host below.
|
||||
hosts.splice(0, hosts.length, ...(await scanHosts()));
|
||||
if (connectionClient?.host) {
|
||||
const inspector = createConnectionClient({ dataDir });
|
||||
const rows: unknown[] = [];
|
||||
for (const host of hosts) {
|
||||
for (const host of [...hosts]) {
|
||||
const inspected = await inspector.host?.({
|
||||
hostId: host.hostId,
|
||||
operation: "inspect",
|
||||
@@ -1169,7 +1214,6 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
const startAtLogin = owner?.owner === "app" ? owner.start_at_login : true;
|
||||
const previousOwner = owner;
|
||||
let runtimeChanged = false;
|
||||
let transientRuntime = false;
|
||||
// Install whoever owns the service. Owner decides who starts and stops
|
||||
// it, not which build runs: skipping the install for a CLI owner (also
|
||||
// the App's own guess when its receipt is missing) left an upgraded App
|
||||
@@ -1239,12 +1283,68 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
const frozen = await readSdkMigrationBlock(dataDir);
|
||||
if (frozen?.frozen !== undefined) throw new Error(frozen.code);
|
||||
}
|
||||
const alreadyRunning = runtimeChanged
|
||||
? false
|
||||
: await waitForRuntimeProcess({ dataDir, attempts: 4 }).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
// One Runtime per data dir. A Runtime already serving it is adopted
|
||||
// when it is this App's build or newer (silently; a newer one is never
|
||||
// stopped or downgraded at startup); an older one is replaced (through
|
||||
// launchd when it is the service's job), and one whose build cannot be
|
||||
// read is adopted but never stopped.
|
||||
const reconciled = await reconcileRunningRuntime({
|
||||
app: {
|
||||
...(packagedBuildNumber === undefined
|
||||
? {}
|
||||
: { buildNumber: packagedBuildNumber }),
|
||||
...(packagedCommit === undefined ? {} : { commit: packagedCommit }),
|
||||
},
|
||||
installedChanged: runtimeChanged,
|
||||
findRunning: () =>
|
||||
waitForRuntimeProcess({ dataDir, attempts: 4 }).then(
|
||||
(attachment) =>
|
||||
attachment.servicePid === null
|
||||
? null
|
||||
: {
|
||||
pid: attachment.servicePid,
|
||||
build: attachment.runtimeBuild,
|
||||
},
|
||||
() => null,
|
||||
),
|
||||
queryServiceState: currentRuntimeServiceState,
|
||||
stopRuntime: () => stopRuntimeProcess({ dataDir }),
|
||||
log: ({ decision, service, running }) =>
|
||||
diagnostics.log({
|
||||
level: decision.action === "adopt-unverified" ? "warn" : "info",
|
||||
component: "desktop.stable_runtime",
|
||||
event: "stable_runtime.adoption",
|
||||
outcome:
|
||||
decision.action === "adopt-unverified" ? "degraded" : "succeeded",
|
||||
details: {
|
||||
action: decision.action,
|
||||
reason:
|
||||
"reason" in decision
|
||||
? decision.reason
|
||||
: (service?.reason ?? "no-runtime-running"),
|
||||
status: service?.state ?? "not-queried",
|
||||
version:
|
||||
running === null
|
||||
? "none"
|
||||
: running.build === undefined
|
||||
? "legacy"
|
||||
: String(running.build.buildNumber ?? "unknown"),
|
||||
},
|
||||
}),
|
||||
});
|
||||
preserveRuntimeOnQuitReason = reconciled.preserveReason;
|
||||
if (reconciled.decision.action === "adopt-unverified") {
|
||||
void dialog
|
||||
.showMessageBox({
|
||||
type: "warning",
|
||||
message: "MemHub",
|
||||
detail: stableRuntimeFailureDetail(
|
||||
new Error("runtime_build_unknown"),
|
||||
),
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
const alreadyRunning = reconciled.adopted;
|
||||
if (owner?.owner !== "cli") {
|
||||
const appBundle = path.resolve(path.dirname(process.execPath), "../..");
|
||||
owner = await claimRuntimeServiceForApp({
|
||||
@@ -1258,8 +1358,13 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
// A durable `launchctl disable` override is a state to report, not a
|
||||
// failure to undo: `installUserService` never re-enables a service the
|
||||
// user or the system disabled. An explicit App launch may still own a
|
||||
// transient Runtime for this UI session. Shutdown stops that process.
|
||||
const serviceMode = await installServiceWithDisabledFallback({
|
||||
// transient Runtime for this UI session; quit stops it because the
|
||||
// service is disabled, which it decides by asking launchd again then.
|
||||
// For an enabled service, `installUserService` restarts the job with
|
||||
// `kickstart -k` (or re-bootstraps a changed plist), so an older
|
||||
// Runtime is replaced through launchd rather than killed into a
|
||||
// respawn of the old binary.
|
||||
await installServiceWithDisabledFallback({
|
||||
install: async () => {
|
||||
await installUserService(layout, {
|
||||
uid: process.getuid?.(),
|
||||
@@ -1291,12 +1396,10 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
});
|
||||
},
|
||||
});
|
||||
transientRuntime = serviceMode === "transient";
|
||||
}
|
||||
if (owner?.owner === "app" && !startAtLogin) {
|
||||
await disableRuntimeService(dataDir);
|
||||
}
|
||||
preserveManagedRuntimeOnQuit = !transientRuntime;
|
||||
} else if (standaloneRuntime) {
|
||||
if (
|
||||
!desktopBackgroundLaunch &&
|
||||
@@ -1665,7 +1768,8 @@ if (desktopVersion !== null) {
|
||||
? readLoginPreference(
|
||||
runtimeDataDir,
|
||||
process.platform,
|
||||
preserveManagedRuntimeOnQuit,
|
||||
runtimeServiceManagedByApp,
|
||||
currentRuntimeServiceState,
|
||||
)
|
||||
: Promise.resolve({
|
||||
supported: false,
|
||||
@@ -1678,9 +1782,21 @@ if (desktopVersion !== null) {
|
||||
runtimeDataDir,
|
||||
process.platform,
|
||||
{ openAtLogin: enabled, userConfirmed: true },
|
||||
preserveManagedRuntimeOnQuit,
|
||||
runtimeServiceManagedByApp,
|
||||
currentRuntimeServiceState,
|
||||
);
|
||||
},
|
||||
readRuntimeHosting: async () => {
|
||||
if (!runtimeDataDir || !app.isPackaged) return "unknown";
|
||||
// A Runtime whose build could not be read is never stopped by it.
|
||||
if (preserveRuntimeOnQuitReason) return "unknown";
|
||||
const service = await currentRuntimeServiceState();
|
||||
return service.state === "enabled"
|
||||
? "background-service"
|
||||
: service.state === "disabled"
|
||||
? "app-hosted"
|
||||
: "unknown";
|
||||
},
|
||||
...(trialProfile
|
||||
? { allowLoginStartup: false, productName: app.getName() }
|
||||
: {}),
|
||||
@@ -1712,7 +1828,10 @@ if (desktopVersion !== null) {
|
||||
// stop and says so by taking the embedded path instead.
|
||||
dataDir: runtimeDataDir ?? "",
|
||||
standalone: app.isPackaged && runtimeDataDir !== null,
|
||||
preserveManagedRuntime: preserveManagedRuntimeOnQuit,
|
||||
// Asked now, not remembered from startup: the user may have switched
|
||||
// the background service on or off since.
|
||||
queryServiceState: currentRuntimeServiceState,
|
||||
preserveReason: preserveRuntimeOnQuitReason,
|
||||
stopSupervisor: async () => {
|
||||
const results = await Promise.allSettled([
|
||||
runtimeSupervisor?.stop() ?? coreSupervisor?.stop(),
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { RuntimeServiceState } from "@one-memory/runtime-control";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
compareRuntimeBuild,
|
||||
decideRuntimeAdoption,
|
||||
type RunningRuntime,
|
||||
reconcileRunningRuntime,
|
||||
} from "./runtime-adoption";
|
||||
|
||||
const app = { buildNumber: 38, commit: "abcdef1234567" };
|
||||
const enabled = (jobPid: number | null): RuntimeServiceState => ({
|
||||
state: "enabled",
|
||||
reason: "service-enabled",
|
||||
jobPid,
|
||||
});
|
||||
const disabled: RuntimeServiceState = {
|
||||
state: "disabled",
|
||||
reason: "service-disabled-by-launchctl",
|
||||
jobPid: null,
|
||||
};
|
||||
const unknown: RuntimeServiceState = {
|
||||
state: "unknown",
|
||||
reason: "service-state-timeout",
|
||||
jobPid: null,
|
||||
};
|
||||
const running = (
|
||||
build: RunningRuntime["build"],
|
||||
pid = 4321,
|
||||
): RunningRuntime => ({ pid, build });
|
||||
|
||||
function reconcile(options: {
|
||||
running: RunningRuntime | null;
|
||||
service: RuntimeServiceState;
|
||||
installedChanged?: boolean;
|
||||
}) {
|
||||
const stopRuntime = vi.fn(async () => "stopped");
|
||||
const queryServiceState = vi.fn(async () => options.service);
|
||||
const result = reconcileRunningRuntime({
|
||||
app,
|
||||
installedChanged: options.installedChanged ?? false,
|
||||
findRunning: async () => options.running,
|
||||
queryServiceState,
|
||||
stopRuntime,
|
||||
});
|
||||
return { result, stopRuntime, queryServiceState };
|
||||
}
|
||||
|
||||
describe("comparing the running Runtime's build with the App's", () => {
|
||||
it("compares build numbers, then commits both sides know", () => {
|
||||
expect(compareRuntimeBuild(app, { buildNumber: 38, commit: null })).toBe(
|
||||
"same",
|
||||
);
|
||||
expect(
|
||||
compareRuntimeBuild(app, { buildNumber: 38, commit: "abcdef1" }),
|
||||
).toBe("same");
|
||||
expect(
|
||||
compareRuntimeBuild(app, { buildNumber: 38, commit: "0123456" }),
|
||||
).toBe("app-newer");
|
||||
expect(compareRuntimeBuild(app, { buildNumber: 37, commit: null })).toBe(
|
||||
"app-newer",
|
||||
);
|
||||
expect(compareRuntimeBuild(app, { buildNumber: 39, commit: null })).toBe(
|
||||
"runtime-newer",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a receipt without build fields as a Runtime older than this App", () => {
|
||||
expect(compareRuntimeBuild(app, undefined)).toBe("app-newer");
|
||||
});
|
||||
|
||||
it("is unknown when either side cannot say", () => {
|
||||
expect(compareRuntimeBuild(app, { buildNumber: null, commit: null })).toBe(
|
||||
"unknown",
|
||||
);
|
||||
expect(compareRuntimeBuild({}, { buildNumber: 38, commit: null })).toBe(
|
||||
"unknown",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("App start adopts or replaces a running Runtime", () => {
|
||||
it("starts one when nothing runs, without asking the service manager", async () => {
|
||||
const { result, stopRuntime, queryServiceState } = reconcile({
|
||||
running: null,
|
||||
service: enabled(null),
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "start" },
|
||||
adopted: false,
|
||||
});
|
||||
expect(queryServiceState).not.toHaveBeenCalled();
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adopts the same build instead of starting a second Runtime", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 38, commit: null }),
|
||||
service: disabled,
|
||||
});
|
||||
await expect(result).resolves.toEqual({
|
||||
decision: { action: "adopt" },
|
||||
adopted: true,
|
||||
preserveReason: null,
|
||||
});
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adopts the service's own job when the service is enabled", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 38, commit: null }, 4321),
|
||||
service: enabled(4321),
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "adopt" },
|
||||
adopted: true,
|
||||
});
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ends with one Runtime when a stray holds the lease beside an enabled service", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 38, commit: null }, 777),
|
||||
service: enabled(null),
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "stop-then-start", reason: "stray-runtime" },
|
||||
adopted: false,
|
||||
});
|
||||
expect(stopRuntime).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not treat a Runtime as a stray when the service state is unknown", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 38, commit: null }, 777),
|
||||
service: unknown,
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "adopt" },
|
||||
});
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces an older Runtime that is the service's job through the service manager", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 37, commit: null }, 4321),
|
||||
service: enabled(4321),
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "replace-via-service" },
|
||||
adopted: false,
|
||||
preserveReason: null,
|
||||
});
|
||||
// No plain kill: launchd would answer it with a respawn of the old job.
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces a legacy service job whose receipt has no build through launchd", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running(undefined, 4321),
|
||||
service: enabled(4321),
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "replace-via-service" },
|
||||
});
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops an older transient or CLI-started Runtime, then starts the right one", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 36, commit: null }, 999),
|
||||
service: disabled,
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "stop-then-start", reason: "app-newer" },
|
||||
adopted: false,
|
||||
});
|
||||
expect(stopRuntime).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("replaces a same-build Runtime when this launch moved `current`", () => {
|
||||
expect(
|
||||
decideRuntimeAdoption({
|
||||
running: running({ buildNumber: 38, commit: null }, 4321),
|
||||
app,
|
||||
service: enabled(4321),
|
||||
installedChanged: true,
|
||||
}),
|
||||
).toEqual({ action: "replace-via-service" });
|
||||
});
|
||||
|
||||
it("adopts a newer Runtime silently and leaves quit to the service state", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 40, commit: null }, 999),
|
||||
service: disabled,
|
||||
});
|
||||
// No special quit pass: with the service disabled, quit stops it like any
|
||||
// adopted Runtime; with it enabled, quit preserves it.
|
||||
await expect(result).resolves.toEqual({
|
||||
decision: { action: "adopt-newer" },
|
||||
adopted: true,
|
||||
preserveReason: null,
|
||||
});
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never stops or downgrades a newer Runtime at startup, even beside an enabled service", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: 40, commit: null }, 999),
|
||||
service: enabled(4321),
|
||||
installedChanged: true,
|
||||
});
|
||||
await expect(result).resolves.toMatchObject({
|
||||
decision: { action: "adopt-newer" },
|
||||
adopted: true,
|
||||
});
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never kills a Runtime whose build cannot be read", async () => {
|
||||
const { result, stopRuntime } = reconcile({
|
||||
running: running({ buildNumber: null, commit: null }, 999),
|
||||
service: disabled,
|
||||
installedChanged: true,
|
||||
});
|
||||
await expect(result).resolves.toEqual({
|
||||
decision: { action: "adopt-unverified" },
|
||||
adopted: true,
|
||||
preserveReason: "runtime-build-unknown",
|
||||
});
|
||||
expect(stopRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import type {
|
||||
RuntimeReceiptBuild,
|
||||
RuntimeServiceState,
|
||||
} from "@one-memory/runtime-control";
|
||||
|
||||
/** The build this App carries. */
|
||||
export interface AppBuild {
|
||||
buildNumber?: number;
|
||||
commit?: string;
|
||||
}
|
||||
|
||||
/** The Runtime found serving the data dir when the App starts. */
|
||||
export interface RunningRuntime {
|
||||
pid: number;
|
||||
/** As published in its receipt; `undefined` for a receipt without the
|
||||
* fields, which only a Runtime older than this App writes. */
|
||||
build: RuntimeReceiptBuild | undefined;
|
||||
}
|
||||
|
||||
export type RuntimeVersionRelation =
|
||||
| "same"
|
||||
| "app-newer"
|
||||
| "runtime-newer"
|
||||
| "unknown";
|
||||
|
||||
export type RuntimeAdoption =
|
||||
/** Nothing is running: install or start this App's Runtime. */
|
||||
| { action: "start" }
|
||||
/** Same build: use it as it is. */
|
||||
| { action: "adopt" }
|
||||
/** Its build cannot be compared: use it, never stop it, tell the user. */
|
||||
| { action: "adopt-unverified" }
|
||||
/** Older, and it is the service manager's job: replace it through the
|
||||
* service manager, never with a plain kill launchd would answer with a
|
||||
* respawn of whatever it last loaded. */
|
||||
| { action: "replace-via-service" }
|
||||
/** Older and transient/CLI-started, or a stray beside an enabled service:
|
||||
* stop it gracefully, then install or start the right one. */
|
||||
| { action: "stop-then-start"; reason: "app-newer" | "stray-runtime" }
|
||||
/** Newer than this App: use it as it is, silently. Never stopped or
|
||||
* downgraded at startup and never joined by a second Runtime; quit treats
|
||||
* it like any adopted Runtime. */
|
||||
| { action: "adopt-newer" };
|
||||
|
||||
/** Compare by build number, then by commit when both sides know theirs. */
|
||||
export function compareRuntimeBuild(
|
||||
app: AppBuild,
|
||||
runtime: RuntimeReceiptBuild | undefined,
|
||||
): RuntimeVersionRelation {
|
||||
if (app.buildNumber === undefined) return "unknown";
|
||||
// Every Runtime that publishes its build carries the fields, even as null.
|
||||
// A receipt without them predates that contract, and so this App.
|
||||
if (runtime === undefined) return "app-newer";
|
||||
if (runtime.buildNumber === null) return "unknown";
|
||||
if (runtime.buildNumber < app.buildNumber) return "app-newer";
|
||||
if (runtime.buildNumber > app.buildNumber) return "runtime-newer";
|
||||
const appCommit = app.commit?.trim().toLowerCase();
|
||||
if (
|
||||
appCommit &&
|
||||
runtime.commit &&
|
||||
!appCommit.startsWith(runtime.commit) &&
|
||||
!runtime.commit.startsWith(appCommit)
|
||||
) {
|
||||
// Same build, different commit: the App's bundle takes over, exactly as
|
||||
// `installPackagedRuntime({ replaceSameBuild: true })` already does.
|
||||
return "app-newer";
|
||||
}
|
||||
return "same";
|
||||
}
|
||||
|
||||
export function decideRuntimeAdoption(input: {
|
||||
running: RunningRuntime | null;
|
||||
app: AppBuild;
|
||||
service: RuntimeServiceState;
|
||||
/** This launch just moved `current` to the App's bundled Runtime, so the
|
||||
* process still running was started from the install it replaced. */
|
||||
installedChanged?: boolean;
|
||||
}): RuntimeAdoption {
|
||||
const { running, service } = input;
|
||||
if (running === null) return { action: "start" };
|
||||
let relation = compareRuntimeBuild(input.app, running.build);
|
||||
if (relation === "same" && input.installedChanged) relation = "app-newer";
|
||||
if (relation === "runtime-newer") return { action: "adopt-newer" };
|
||||
if (relation === "unknown") return { action: "adopt-unverified" };
|
||||
const serviceJob = service.jobPid !== null && service.jobPid === running.pid;
|
||||
if (relation === "app-newer") {
|
||||
return serviceJob
|
||||
? { action: "replace-via-service" }
|
||||
: { action: "stop-then-start", reason: "app-newer" };
|
||||
}
|
||||
// One Runtime per data dir: with the service on, the one that stays is the
|
||||
// service's own job, not a transient or CLI process holding its lease.
|
||||
if (service.state === "enabled" && !serviceJob) {
|
||||
return { action: "stop-then-start", reason: "stray-runtime" };
|
||||
}
|
||||
return { action: "adopt" };
|
||||
}
|
||||
|
||||
export interface RuntimeReconcileDependencies {
|
||||
app: AppBuild;
|
||||
installedChanged?: boolean;
|
||||
findRunning(): Promise<RunningRuntime | null>;
|
||||
queryServiceState(): Promise<RuntimeServiceState>;
|
||||
/** Graceful stop of the one verified Runtime: in-flight writes finish. */
|
||||
stopRuntime(): Promise<unknown>;
|
||||
log?(event: {
|
||||
decision: RuntimeAdoption;
|
||||
service: RuntimeServiceState | null;
|
||||
running: RunningRuntime | null;
|
||||
}): Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface RuntimeReconcileResult {
|
||||
decision: RuntimeAdoption;
|
||||
/** The running Runtime stays and the caller must not start another. */
|
||||
adopted: boolean;
|
||||
/** Quit must leave the adopted Runtime alone whatever the service state:
|
||||
* this App could not prove it is its own build. */
|
||||
preserveReason: string | null;
|
||||
}
|
||||
|
||||
/** Decide what to do with a Runtime already serving the data dir at App start,
|
||||
* and do the part that has to happen before the normal start path runs. */
|
||||
export async function reconcileRunningRuntime(
|
||||
dependencies: RuntimeReconcileDependencies,
|
||||
): Promise<RuntimeReconcileResult> {
|
||||
const running = await dependencies.findRunning();
|
||||
const service =
|
||||
running === null ? null : await dependencies.queryServiceState();
|
||||
const decision = decideRuntimeAdoption({
|
||||
running,
|
||||
app: dependencies.app,
|
||||
service: service ?? {
|
||||
state: "unknown",
|
||||
reason: "service-state-query-failed",
|
||||
jobPid: null,
|
||||
},
|
||||
installedChanged: dependencies.installedChanged ?? false,
|
||||
});
|
||||
await dependencies.log?.({ decision, service, running });
|
||||
switch (decision.action) {
|
||||
case "start":
|
||||
case "replace-via-service":
|
||||
return { decision, adopted: false, preserveReason: null };
|
||||
case "stop-then-start":
|
||||
await dependencies.stopRuntime();
|
||||
return { decision, adopted: false, preserveReason: null };
|
||||
case "adopt":
|
||||
case "adopt-newer":
|
||||
return { decision, adopted: true, preserveReason: null };
|
||||
case "adopt-unverified":
|
||||
return {
|
||||
decision,
|
||||
adopted: true,
|
||||
preserveReason: "runtime-build-unknown",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,96 +1,162 @@
|
||||
import type {
|
||||
RuntimeServiceState,
|
||||
StopAllRuntimesResult,
|
||||
} from "@one-memory/runtime-control";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { shutdownRuntime } from "./runtime-shutdown";
|
||||
|
||||
const { stopRuntimeProcess } = vi.hoisted(() => ({
|
||||
stopRuntimeProcess: vi.fn<() => Promise<"stopped" | "not-running">>(
|
||||
async () => "stopped",
|
||||
),
|
||||
const { stopAllRuntimeProcesses } = vi.hoisted(() => ({
|
||||
stopAllRuntimeProcesses: vi.fn<
|
||||
(options: { dataDir: string }) => Promise<StopAllRuntimesResult>
|
||||
>(async () => ({ stopped: [4321], unverified: [] })),
|
||||
}));
|
||||
|
||||
vi.mock("@one-memory/runtime-control", () => ({ stopRuntimeProcess }));
|
||||
vi.mock("@one-memory/runtime-control", () => ({ stopAllRuntimeProcesses }));
|
||||
|
||||
const stopMock = vi.mocked(stopRuntimeProcess);
|
||||
const stopMock = vi.mocked(stopAllRuntimeProcesses);
|
||||
|
||||
const enabled: RuntimeServiceState = {
|
||||
state: "enabled",
|
||||
reason: "service-enabled",
|
||||
jobPid: 4321,
|
||||
};
|
||||
const disabled = (
|
||||
reason: RuntimeServiceState["reason"] = "service-enable-marker-absent",
|
||||
): RuntimeServiceState => ({ state: "disabled", reason, jobPid: null });
|
||||
const unknown: RuntimeServiceState = {
|
||||
state: "unknown",
|
||||
reason: "service-state-timeout",
|
||||
jobPid: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
stopMock.mockClear();
|
||||
stopMock.mockResolvedValue("stopped");
|
||||
stopMock.mockReset();
|
||||
stopMock.mockResolvedValue({ stopped: [4321], unverified: [] });
|
||||
});
|
||||
|
||||
describe("BUG-12: a full quit stops the standalone Runtime process", () => {
|
||||
it("detaches the UI without stopping a managed Runtime service", async () => {
|
||||
describe("App quit with the background service enabled", () => {
|
||||
it("never stops the Runtime", async () => {
|
||||
const stopSupervisor = vi.fn(async () => {});
|
||||
const events: string[] = [];
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
preserveManagedRuntime: true,
|
||||
queryServiceState: async () => enabled,
|
||||
stopSupervisor,
|
||||
onDiagnostic: (event) => {
|
||||
events.push(event.detail);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ runtime: "unchanged" });
|
||||
expect(result).toEqual({
|
||||
runtime: "preserved",
|
||||
reason: "background-service-enabled",
|
||||
});
|
||||
expect(stopSupervisor).toHaveBeenCalledOnce();
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
expect(events).toEqual(["managed-runtime-preserved"]);
|
||||
expect(events).toEqual(["runtime-preserved:background-service-enabled"]);
|
||||
});
|
||||
|
||||
it("stops the Runtime process in a packaged build", async () => {
|
||||
const stopSupervisor = vi.fn(async () => {});
|
||||
it("does not stop a transient Runtime this App started before the service was enabled", async () => {
|
||||
// Startup fell back to a transient child (service launchctl-disabled) and
|
||||
// the user re-enabled the service later. Nothing startup saw is an input
|
||||
// here: quit asks the service manager again and preserves the Runtime.
|
||||
const queryServiceState = vi.fn(async () => enabled);
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
stopSupervisor,
|
||||
queryServiceState,
|
||||
stopSupervisor: async () => {},
|
||||
});
|
||||
|
||||
expect(queryServiceState).toHaveBeenCalledOnce();
|
||||
expect(result.runtime).toBe("preserved");
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("App quit with the background service disabled", () => {
|
||||
it("stops every Runtime of the data dir: the App's child and an external one", async () => {
|
||||
stopMock.mockResolvedValue({ stopped: [1111, 2222], unverified: [] });
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
queryServiceState: async () => disabled(),
|
||||
stopSupervisor: async () => {},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ runtime: "stopped" });
|
||||
expect(stopMock).toHaveBeenCalledWith({ dataDir: "/tmp/runtime-data" });
|
||||
expect(stopSupervisor).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not touch a separate Runtime process in an embedded build", async () => {
|
||||
const stopSupervisor = vi.fn(async () => {});
|
||||
it.each([
|
||||
"service-not-installed",
|
||||
"service-disabled-by-launchctl",
|
||||
"service-enable-marker-absent",
|
||||
] as const)("treats %s as disabled", async (reason) => {
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
queryServiceState: async () => disabled(reason),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ runtime: "stopped" });
|
||||
expect(stopMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports a live lease owner it could not verify instead of signalling it", async () => {
|
||||
stopMock.mockResolvedValue({ stopped: [], unverified: [9999] });
|
||||
const events: string[] = [];
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: false,
|
||||
stopSupervisor,
|
||||
standalone: true,
|
||||
queryServiceState: async () => disabled(),
|
||||
onDiagnostic: (event) => {
|
||||
events.push(`${event.outcome}:${event.detail}`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ runtime: "unchanged" });
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
expect(stopSupervisor).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual({
|
||||
runtime: "failed",
|
||||
detail: "unverified-runtime-left-running",
|
||||
});
|
||||
expect(events).toEqual(["degraded:unverified-runtime-left-running"]);
|
||||
});
|
||||
|
||||
it("tears the supervisor down before it stops the process", async () => {
|
||||
const order: string[] = [];
|
||||
stopMock.mockImplementation(async () => {
|
||||
order.push("process");
|
||||
return "stopped";
|
||||
return { stopped: [4321], unverified: [] };
|
||||
});
|
||||
|
||||
await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
queryServiceState: async () => {
|
||||
order.push("query");
|
||||
return disabled();
|
||||
},
|
||||
stopSupervisor: async () => {
|
||||
order.push("supervisor");
|
||||
},
|
||||
});
|
||||
|
||||
expect(order).toEqual(["supervisor", "process"]);
|
||||
expect(order).toEqual(["supervisor", "query", "process"]);
|
||||
});
|
||||
|
||||
it("reports not-running instead of throwing when nothing is attached", async () => {
|
||||
stopMock.mockResolvedValue("not-running");
|
||||
it("reports not-running when nothing serves the data dir", async () => {
|
||||
stopMock.mockResolvedValue({ stopped: [], unverified: [] });
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
stopSupervisor: async () => {},
|
||||
queryServiceState: async () => disabled(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ runtime: "not-running" });
|
||||
@@ -103,7 +169,7 @@ describe("BUG-12: a full quit stops the standalone Runtime process", () => {
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
stopSupervisor: async () => {},
|
||||
queryServiceState: async () => disabled(),
|
||||
onDiagnostic: async (event) => {
|
||||
events.push(event.outcome);
|
||||
},
|
||||
@@ -117,24 +183,100 @@ describe("BUG-12: a full quit stops the standalone Runtime process", () => {
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
queryServiceState: async () => disabled(),
|
||||
stopSupervisor: async () => {
|
||||
throw new Error("supervisor exploded");
|
||||
},
|
||||
});
|
||||
|
||||
// The process is what must not be left behind; a supervisor error is not
|
||||
// an excuse to skip it.
|
||||
expect(stopMock).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual({
|
||||
runtime: "failed",
|
||||
detail: "supervisor exploded",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("App quit when the service state cannot be determined", () => {
|
||||
it("stops nothing on a timeout and says so", async () => {
|
||||
const events: string[] = [];
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
queryServiceState: async () => unknown,
|
||||
onDiagnostic: (event) => {
|
||||
events.push(`${event.outcome}:${event.detail}`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
runtime: "preserved",
|
||||
reason: "service-state-unknown:service-state-timeout",
|
||||
});
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
expect(events).toEqual([
|
||||
"degraded:runtime-preserved:service-state-unknown:service-state-timeout",
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops nothing when the query itself throws", async () => {
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
queryServiceState: async () => {
|
||||
throw new Error("launchctl exploded");
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
runtime: "preserved",
|
||||
reason: "service-state-unknown:service-state-query-failed",
|
||||
});
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Runtimes quit never touches", () => {
|
||||
it("leaves a Runtime whose build could not be read, even with the service disabled", async () => {
|
||||
const queryServiceState = vi.fn(async () => disabled());
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: true,
|
||||
queryServiceState,
|
||||
preserveReason: "runtime-build-unknown",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
runtime: "preserved",
|
||||
reason: "runtime-build-unknown",
|
||||
});
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not touch a separate Runtime process in an embedded build", async () => {
|
||||
const stopSupervisor = vi.fn(async () => {});
|
||||
const queryServiceState = vi.fn(async () => disabled());
|
||||
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: false,
|
||||
queryServiceState,
|
||||
stopSupervisor,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ runtime: "unchanged" });
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
expect(queryServiceState).not.toHaveBeenCalled();
|
||||
expect(stopSupervisor).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports a supervisor failure in an embedded build too", async () => {
|
||||
const result = await shutdownRuntime({
|
||||
dataDir: "/tmp/runtime-data",
|
||||
standalone: false,
|
||||
queryServiceState: async () => disabled(),
|
||||
stopSupervisor: async () => {
|
||||
throw new Error("supervisor exploded");
|
||||
},
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { stopRuntimeProcess } from "@one-memory/runtime-control";
|
||||
import {
|
||||
type RuntimeServiceState,
|
||||
type StopAllRuntimesResult,
|
||||
stopAllRuntimeProcesses,
|
||||
} from "@one-memory/runtime-control";
|
||||
|
||||
export interface RuntimeShutdownOptions {
|
||||
dataDir: string;
|
||||
/** Packaged builds run the Runtime as their own process; a dev build's Core
|
||||
* is this process's child and is stopped by the supervisor. */
|
||||
standalone: boolean;
|
||||
/** A managed OS service outlives Electron; a transient App-spawned Runtime
|
||||
* is still stopped on quit. */
|
||||
preserveManagedRuntime?: boolean;
|
||||
/** Asks the service manager whether the background service is on. Called
|
||||
* at quit time, every time: what startup saw may no longer be true. */
|
||||
queryServiceState: () => Promise<RuntimeServiceState>;
|
||||
/** Set when startup adopted a Runtime whose build it could not read. This
|
||||
* App never stops such a Runtime. A newer Runtime gets no such pass: it
|
||||
* follows the service state like any adopted Runtime. */
|
||||
preserveReason?: string | null;
|
||||
stopRuntimes?: (options: {
|
||||
dataDir: string;
|
||||
}) => Promise<StopAllRuntimesResult>;
|
||||
/** Tear down this process's supervisor state (Core child, broker). */
|
||||
stopSupervisor?: () => Promise<void>;
|
||||
onDiagnostic?: (event: {
|
||||
@@ -19,16 +30,22 @@ export interface RuntimeShutdownOptions {
|
||||
export type RuntimeShutdownResult =
|
||||
| { runtime: "stopped" }
|
||||
| { runtime: "not-running" }
|
||||
| { runtime: "preserved"; reason: string }
|
||||
| { runtime: "unchanged" }
|
||||
| { runtime: "failed"; detail: string };
|
||||
|
||||
/** Stop everything a full quit has to stop.
|
||||
/** Stop everything a full quit has to stop, and nothing else.
|
||||
*
|
||||
* Quitting used to tear down this process's own handles and nothing else. In a
|
||||
* packaged build the Runtime is a separate process, so it outlived the app: the
|
||||
* window and the tray were gone and the Runtime was still there, and its
|
||||
* receipt still named a pid, so the next launch concluded it was already
|
||||
* running and attached to a process that no longer answered.
|
||||
* Exactly one Runtime serves a data dir. Whether quitting the App ends it is
|
||||
* decided here, at quit time, from the service manager's answer:
|
||||
*
|
||||
* - background service enabled: the Runtime is the user's background memory
|
||||
* service. Quit never stops it, whoever started the process, including a
|
||||
* transient Runtime this App started earlier.
|
||||
* - background service disabled: the Runtime lives only while the App does.
|
||||
* Quit stops every Runtime of this data dir: the App's own child, one
|
||||
* `memhub start` launched, a leftover from an earlier session.
|
||||
* - state unknown (query failed or timed out): stop nothing, and say so.
|
||||
*
|
||||
* A failure here is reported, never thrown. By the time this runs the app is
|
||||
* already quitting, and throwing would only turn a failure someone could see
|
||||
@@ -39,15 +56,6 @@ export async function shutdownRuntime(
|
||||
): Promise<RuntimeShutdownResult> {
|
||||
const stopSupervisor = options.stopSupervisor ?? (async () => {});
|
||||
const supervisorFailure = await settle(stopSupervisor);
|
||||
if (options.preserveManagedRuntime) {
|
||||
await options.onDiagnostic?.({
|
||||
outcome: supervisorFailure ? "degraded" : "succeeded",
|
||||
detail: supervisorFailure ?? "managed-runtime-preserved",
|
||||
});
|
||||
return supervisorFailure
|
||||
? { runtime: "failed", detail: supervisorFailure }
|
||||
: { runtime: "unchanged" };
|
||||
}
|
||||
if (!options.standalone) {
|
||||
if (supervisorFailure) {
|
||||
await options.onDiagnostic?.({
|
||||
@@ -58,16 +66,52 @@ export async function shutdownRuntime(
|
||||
}
|
||||
return { runtime: "unchanged" };
|
||||
}
|
||||
try {
|
||||
const result = await stopRuntimeProcess({ dataDir: options.dataDir });
|
||||
const preserved = async (
|
||||
reason: string,
|
||||
outcome: "succeeded" | "degraded",
|
||||
): Promise<RuntimeShutdownResult> => {
|
||||
await options.onDiagnostic?.({
|
||||
outcome: supervisorFailure ? "degraded" : "succeeded",
|
||||
detail: supervisorFailure ?? result,
|
||||
outcome: supervisorFailure ? "degraded" : outcome,
|
||||
detail: supervisorFailure ?? `runtime-preserved:${reason}`,
|
||||
});
|
||||
return supervisorFailure
|
||||
? { runtime: "failed", detail: supervisorFailure }
|
||||
: { runtime: "preserved", reason };
|
||||
};
|
||||
if (options.preserveReason) {
|
||||
return preserved(options.preserveReason, "succeeded");
|
||||
}
|
||||
const service = await options.queryServiceState().catch(
|
||||
(): RuntimeServiceState => ({
|
||||
state: "unknown",
|
||||
reason: "service-state-query-failed",
|
||||
jobPid: null,
|
||||
}),
|
||||
);
|
||||
if (service.state === "enabled") {
|
||||
return preserved("background-service-enabled", "succeeded");
|
||||
}
|
||||
if (service.state !== "disabled") {
|
||||
// Fail safe: a Runtime stopped by mistake takes the user's background
|
||||
// memory with it; one left running by mistake is only a process.
|
||||
return preserved(`service-state-unknown:${service.reason}`, "degraded");
|
||||
}
|
||||
try {
|
||||
const result = await (options.stopRuntimes ?? stopAllRuntimeProcesses)({
|
||||
dataDir: options.dataDir,
|
||||
});
|
||||
const stopped = result.stopped.length > 0 ? "stopped" : "not-running";
|
||||
const unverified =
|
||||
result.unverified.length > 0 ? "unverified-runtime-left-running" : null;
|
||||
await options.onDiagnostic?.({
|
||||
outcome: supervisorFailure || unverified ? "degraded" : "succeeded",
|
||||
detail: supervisorFailure ?? unverified ?? `${stopped}:${service.reason}`,
|
||||
});
|
||||
if (supervisorFailure) {
|
||||
return { runtime: "failed", detail: supervisorFailure };
|
||||
}
|
||||
return { runtime: result };
|
||||
if (unverified) return { runtime: "failed", detail: unverified };
|
||||
return { runtime: stopped };
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : "unknown";
|
||||
await options.onDiagnostic?.({ outcome: "degraded", detail });
|
||||
|
||||
@@ -143,6 +143,7 @@ const SAFE_STABLE_RUNTIME_ERROR_CODES = new Set([
|
||||
"sdk_migration_status_unavailable",
|
||||
"sdk_migration_incomplete",
|
||||
"runtime_service_not_ready",
|
||||
"runtime_build_unknown",
|
||||
]);
|
||||
|
||||
/** Every migration code is a stable token Core or the orchestrator chose;
|
||||
@@ -242,6 +243,8 @@ export const STABLE_RUNTIME_USER_MESSAGES: Record<string, string> = {
|
||||
"记忆数据格式升级没有完成,MemHub 暂时无法打开。请重新启动重试;如果仍然失败,请把这个提示反馈给我们,原有记忆数据不会丢失。",
|
||||
runtime_service_not_ready:
|
||||
"记忆服务仍在首次启动,请稍候后重试。你的记忆数据不会受到影响。",
|
||||
runtime_build_unknown:
|
||||
"无法确认正在运行的 MemHub 后台版本,App 已连接到它,但不会停止或替换它。如遇问题,请更新 App 或重启后台服务;你的记忆数据不会受到影响。",
|
||||
desktop_start_failed:
|
||||
"MemHub 启动失败。请重新启动;如果仍然打不开,请重新安装,你的记忆数据不会受到影响。",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
/**
|
||||
* #230: DeepSeek Harness has no single host, so the app page used to grey the
|
||||
* switch out with `unsupported_host` and offered no other way to grant recall.
|
||||
* The one switch now covers every detected profile.
|
||||
*/
|
||||
|
||||
import type { SourceResponse } from "@one-memory/contracts";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { ApplicationDetail } from "./ApplicationDetail";
|
||||
import { readConnectionPreferences } from "./connection-preferences";
|
||||
import { resetConnectionReconcile } from "./connection-reconcile";
|
||||
import { harnessBridge } from "./harness-profiles.test-fixture";
|
||||
|
||||
const source = {
|
||||
source_id: "deepseek-harness",
|
||||
display_name: "DeepSeek Harness",
|
||||
authorized: true,
|
||||
sync_enabled: true,
|
||||
capabilities: {
|
||||
historical_import: "implemented",
|
||||
incremental_import: "implemented",
|
||||
live_sync: "implemented",
|
||||
recall: "implemented",
|
||||
installer: "implemented",
|
||||
verification: "real_replayed",
|
||||
},
|
||||
} as SourceResponse;
|
||||
|
||||
function renderDetail() {
|
||||
return render(
|
||||
<ApplicationDetail
|
||||
source={source}
|
||||
sources={[source]}
|
||||
stale={false}
|
||||
setSync={vi.fn()}
|
||||
refresh={vi.fn().mockResolvedValue({ sources: [source] })}
|
||||
onBack={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function profileItems() {
|
||||
return within(
|
||||
screen.getByRole("list", { name: "DeepSeek Harness 的 Profile" }),
|
||||
)
|
||||
.getAllByRole("listitem")
|
||||
.map((item) => item.textContent);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
resetConnectionReconcile();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.oneMemory = undefined;
|
||||
window.localStorage.clear();
|
||||
resetConnectionReconcile();
|
||||
});
|
||||
|
||||
it("lists every detected profile with its own state", async () => {
|
||||
harnessBridge({ desktop: "approved", web: "not-installed" });
|
||||
renderDetail();
|
||||
await waitFor(() =>
|
||||
expect(profileItems()).toEqual(["desktop:已接入", "web:未接入"]),
|
||||
);
|
||||
const insertion = screen.getByRole("switch", { name: "一键插入应用" });
|
||||
expect(insertion.hasAttribute("disabled")).toBe(false);
|
||||
expect(screen.queryByText(/当前安装不支持一键插入/)).toBeNull();
|
||||
});
|
||||
|
||||
it("turns every profile on with the one switch and remembers the choice", async () => {
|
||||
const { api, state } = harnessBridge({
|
||||
desktop: "not-installed",
|
||||
web: "not-installed",
|
||||
});
|
||||
renderDetail();
|
||||
const insertion = await screen.findByRole("switch", { name: "一键插入应用" });
|
||||
await waitFor(() => expect(profileItems()).toHaveLength(2));
|
||||
expect(insertion.getAttribute("aria-checked")).toBe("false");
|
||||
fireEvent.click(insertion);
|
||||
await waitFor(() =>
|
||||
expect(profileItems()).toEqual(["desktop:已接入", "web:已接入"]),
|
||||
);
|
||||
expect(api.installRecall.mock.calls.map(([hostId]) => hostId)).toEqual([
|
||||
"deepseek-harness:desktop",
|
||||
"deepseek-harness:web",
|
||||
]);
|
||||
expect([...state.values()]).toEqual(["approved", "approved"]);
|
||||
expect(insertion.getAttribute("aria-checked")).toBe("true");
|
||||
// Not "未接通": the self-checks the grants recorded were read back.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: /已接通|未接通|检测中/ }).textContent,
|
||||
).toBe("已接通"),
|
||||
);
|
||||
// Remembered, so a profile found on a later launch is connected too.
|
||||
expect(readConnectionPreferences().hostSourceIds).toEqual([
|
||||
"deepseek-harness",
|
||||
]);
|
||||
});
|
||||
|
||||
it("names the profile that failed and keeps the others on", async () => {
|
||||
const { state } = harnessBridge(
|
||||
{ desktop: "not-installed", web: "not-installed" },
|
||||
{ failInstall: ["web"] },
|
||||
);
|
||||
renderDetail();
|
||||
const insertion = await screen.findByRole("switch", { name: "一键插入应用" });
|
||||
await waitFor(() => expect(profileItems()).toHaveLength(2));
|
||||
fireEvent.click(insertion);
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toContain("以下 Profile 未能接入:web");
|
||||
expect(alert.textContent).toContain("应用控制通道暂不可用");
|
||||
await waitFor(() =>
|
||||
expect(profileItems()).toEqual(["desktop:已接入", "web:未接入"]),
|
||||
);
|
||||
expect(state.get("deepseek-harness:desktop")).toBe("approved");
|
||||
expect(insertion.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("turns every linked profile off with the one switch", async () => {
|
||||
const { api, state } = harnessBridge({
|
||||
desktop: "approved",
|
||||
web: "approved",
|
||||
});
|
||||
renderDetail();
|
||||
const insertion = await screen.findByRole("switch", { name: "一键插入应用" });
|
||||
await waitFor(() =>
|
||||
expect(insertion.getAttribute("aria-checked")).toBe("true"),
|
||||
);
|
||||
fireEvent.click(insertion);
|
||||
await waitFor(() =>
|
||||
expect(profileItems()).toEqual(["desktop:未接入", "web:未接入"]),
|
||||
);
|
||||
expect(api.uninstallRecall).toHaveBeenCalledTimes(2);
|
||||
expect([...state.values()]).toEqual(["not-installed", "not-installed"]);
|
||||
expect(insertion.getAttribute("aria-checked")).toBe("false");
|
||||
expect(readConnectionPreferences().hostSourceIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("names the profile whose connection could not be removed", async () => {
|
||||
harnessBridge(
|
||||
{ desktop: "approved", web: "approved" },
|
||||
{
|
||||
failUninstall: ["desktop"],
|
||||
},
|
||||
);
|
||||
renderDetail();
|
||||
const insertion = await screen.findByRole("switch", { name: "一键插入应用" });
|
||||
await waitFor(() =>
|
||||
expect(insertion.getAttribute("aria-checked")).toBe("true"),
|
||||
);
|
||||
fireEvent.click(insertion);
|
||||
// Removal failed but revocation went through: the grant is gone, the file
|
||||
// is left for the person to check.
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"已撤销以下 Profile 的记忆权限,但连接文件没有全部移除,请核对:desktop",
|
||||
);
|
||||
});
|
||||
|
||||
it("says how to get a profile when none is detected", async () => {
|
||||
harnessBridge({});
|
||||
renderDetail();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"还没有检测到 DeepSeek Harness 的 Profile。创建 Profile 后再回到此页。",
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen
|
||||
.getByRole("switch", { name: "一键插入应用" })
|
||||
.hasAttribute("disabled"),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -2,15 +2,21 @@ import type { SourceResponse } from "@one-memory/contracts";
|
||||
import type { MessageKey } from "@one-memory/i18n";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AppMark } from "./app-mark";
|
||||
import { connectionHostId, preferenceHasSource } from "./connection-hosts";
|
||||
import {
|
||||
connectionHostId,
|
||||
isProfileSource,
|
||||
preferenceHasSource,
|
||||
profileName,
|
||||
} from "./connection-hosts";
|
||||
import {
|
||||
readConnectionPreferences,
|
||||
writeConnectionPreference,
|
||||
} from "./connection-preferences";
|
||||
import { noteUserHostChoice } from "./connection-reconcile";
|
||||
import type { RecallLinkCheck } from "./global";
|
||||
import type { RecallHostStatus, RecallLinkCheck } from "./global";
|
||||
import {
|
||||
disconnectHost,
|
||||
disconnectProfiles,
|
||||
enableHostConnection,
|
||||
hostCapabilities,
|
||||
hostConnectionApproved,
|
||||
@@ -19,8 +25,11 @@ import {
|
||||
hostWriteReason,
|
||||
matchHost,
|
||||
probeHost,
|
||||
profileHosts,
|
||||
profileWriteFailures,
|
||||
refreshRecallHosts,
|
||||
setHostMemoryPermission,
|
||||
sourceConnectionApproved,
|
||||
UNSUPPORTED_NOTE,
|
||||
useRecallHosts,
|
||||
} from "./host-state";
|
||||
@@ -63,6 +72,24 @@ function connectionLabel(input: {
|
||||
return "未开启关联";
|
||||
}
|
||||
|
||||
/** The recorded self-check across the profiles that may use memory: wired
|
||||
* only when every one of them is, otherwise the first one that is not. */
|
||||
function profilesProbeState(profiles: RecallHostStatus[]) {
|
||||
const linked = profiles.filter(hostConnectionApproved);
|
||||
if (!linked.length) return null;
|
||||
for (const host of linked) {
|
||||
const state = host.selfCheck?.state ?? null;
|
||||
if (state !== "wired") return state;
|
||||
}
|
||||
return "wired";
|
||||
}
|
||||
|
||||
function profileList(hosts: readonly ProfileHost[]) {
|
||||
return hosts.map((host) => profileName(host.hostId)).join(", ");
|
||||
}
|
||||
|
||||
type ProfileHost = Pick<RecallHostStatus, "hostId">;
|
||||
|
||||
function SourceIcon({ source }: { source: SourceResponse }) {
|
||||
return (
|
||||
<AppMark
|
||||
@@ -215,7 +242,10 @@ export function ApplicationDetail({
|
||||
if (!mounted.current || version !== epoch.current) return;
|
||||
setRead({ sourceId: id, state: "ready" });
|
||||
setCheckedAt(new Date().toISOString());
|
||||
if (matched?.installed) void probeLink(matched.hostId);
|
||||
// A per-profile source has no single host to probe; its status comes
|
||||
// from each profile's recorded self-check instead.
|
||||
if (matched?.installed && !isProfileSource(id))
|
||||
void probeLink(matched.hostId);
|
||||
else setLink(null);
|
||||
} catch {
|
||||
if (mounted.current && version === epoch.current)
|
||||
@@ -292,10 +322,14 @@ export function ApplicationDetail({
|
||||
}
|
||||
const current = read.sourceId === source.source_id ? read : null;
|
||||
const host = matchHost(hosts, source.source_id);
|
||||
const profileSource = isProfileSource(source.source_id);
|
||||
const profiles = profileHosts(hosts, source.source_id);
|
||||
const loading = !current || current.state === "loading";
|
||||
const readFailed = current?.state === "error";
|
||||
const approved = hostConnectionApproved(host);
|
||||
const unsupported = hostConnectionUnsupported(host);
|
||||
const approved = profileSource
|
||||
? sourceConnectionApproved(hosts, source.source_id)
|
||||
: hostConnectionApproved(host);
|
||||
const unsupported = !profileSource && hostConnectionUnsupported(host);
|
||||
const insertionChecked = insertionIntent ?? approved;
|
||||
const memoryChecked = memoryIntent ?? host?.permission?.state === "approved";
|
||||
const probing =
|
||||
@@ -305,9 +339,10 @@ export function ApplicationDetail({
|
||||
loading ||
|
||||
readFailed ||
|
||||
(stale && current?.state !== "ready");
|
||||
const probeState =
|
||||
link?.probe.state ??
|
||||
(linkPending || linkFailed ? null : (host?.selfCheck?.state ?? null));
|
||||
const probeState = profileSource
|
||||
? profilesProbeState(profiles)
|
||||
: (link?.probe.state ??
|
||||
(linkPending || linkFailed ? null : (host?.selfCheck?.state ?? null)));
|
||||
const stateText = connectionLabel({
|
||||
approved,
|
||||
insertionOn: insertionChecked,
|
||||
@@ -326,10 +361,43 @@ export function ApplicationDetail({
|
||||
? { reason: failureNote(installError.failure) }
|
||||
: {}),
|
||||
});
|
||||
async function applyProfiles(enabled: boolean) {
|
||||
if (enabled) {
|
||||
try {
|
||||
await enableHostConnection(source.source_id);
|
||||
} finally {
|
||||
// Each grant records its self-check on the desktop, and the status row
|
||||
// reads those; nothing else re-reads them while this page is open.
|
||||
await refreshRecallHosts().catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const results = await disconnectProfiles(source.source_id);
|
||||
const failed = results.filter((item) => item.outcome.state === "failed");
|
||||
const retained = results.filter(
|
||||
(item) =>
|
||||
item.outcome.state === "revoked" || item.outcome.state === "review",
|
||||
);
|
||||
if (retained.length)
|
||||
setInstallError({
|
||||
key: "appProfilesRevokedRetained",
|
||||
params: { profiles: profileList(retained) },
|
||||
});
|
||||
const first = failed[0]?.outcome;
|
||||
if (first?.state === "failed")
|
||||
throw Object.assign(Error("disconnect_failed"), {
|
||||
profiles: failed.map((item) => profileName(item.hostId)),
|
||||
failure: {
|
||||
reason: first.reason,
|
||||
rereadFailed: first.rereadFailed,
|
||||
} satisfies HostFailure,
|
||||
});
|
||||
}
|
||||
async function applyInsertion(enabled: boolean) {
|
||||
const bridge = window.oneMemory;
|
||||
if (!bridge) return;
|
||||
setInstallError("");
|
||||
if (profileSource) return applyProfiles(enabled);
|
||||
if (enabled) {
|
||||
await enableHostConnection(source.source_id);
|
||||
void probeLink(hostId);
|
||||
@@ -385,6 +453,30 @@ export function ApplicationDetail({
|
||||
}
|
||||
// A failed turn-on keeps the stored choice on; a failed turn-off
|
||||
// shows the grant that is really still there after the re-read.
|
||||
// Per-profile sources name the profiles that failed; the others
|
||||
// keep what they got.
|
||||
const failedProfiles =
|
||||
profileWriteFailures(error) ??
|
||||
(error as { profiles?: string[] }).profiles ??
|
||||
null;
|
||||
if (failedProfiles) {
|
||||
setInstallError({
|
||||
key: wanted
|
||||
? "appProfilesConnectFailed"
|
||||
: "appProfilesDisconnectFailed",
|
||||
params: { profiles: failedProfiles.join(", ") },
|
||||
failure: wanted
|
||||
? {
|
||||
reason: hostWriteReason(error),
|
||||
rereadFailed: hostRereadFailed(error),
|
||||
}
|
||||
: ((error as { failure?: HostFailure }).failure ?? {
|
||||
reason: null,
|
||||
rereadFailed: false,
|
||||
}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
setInstallError(
|
||||
wanted
|
||||
? {
|
||||
@@ -561,6 +653,31 @@ export function ApplicationDetail({
|
||||
<div>
|
||||
<h2>{text("一键插入应用")}</h2>
|
||||
<p>{text("安装记忆连接,并默认允许此应用使用已授权记忆。")}</p>
|
||||
{profileSource &&
|
||||
(profiles.length ? (
|
||||
<>
|
||||
<ul
|
||||
className="r6-app-profiles"
|
||||
aria-label={t("appProfilesLabel", {
|
||||
name: source.display_name,
|
||||
})}
|
||||
>
|
||||
{profiles.map((profile) => (
|
||||
<li key={profile.hostId}>
|
||||
{t(
|
||||
hostConnectionApproved(profile)
|
||||
? "appProfileOn"
|
||||
: "appProfileOff",
|
||||
{ profile: profileName(profile.hostId) },
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p>{t("appProfilesHint")}</p>
|
||||
</>
|
||||
) : (
|
||||
<p>{t("appProfilesNone", { name: source.display_name })}</p>
|
||||
))}
|
||||
{unsupported && (
|
||||
<p>{`${source.display_name} ${text(UNSUPPORTED_NOTE)}`}</p>
|
||||
)}
|
||||
@@ -578,6 +695,7 @@ export function ApplicationDetail({
|
||||
stale ||
|
||||
appLocationNote !== null ||
|
||||
unsupported ||
|
||||
(profileSource && !profiles.length) ||
|
||||
source.capabilities?.installer !== "implemented" ||
|
||||
source.capabilities?.recall !== "implemented" ||
|
||||
!hostCapabilities().install
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
hostBridgeAvailable,
|
||||
hostConnectionApproved,
|
||||
hostConnectionUnknown,
|
||||
sourceConnectionApproved,
|
||||
sourceConnectionIncomplete,
|
||||
useRecallHosts,
|
||||
} from "./host-state";
|
||||
import { subscribePoll } from "./poll-scheduler";
|
||||
@@ -737,12 +739,7 @@ export function ApplicationsHome({
|
||||
failure.kind === "host"
|
||||
? preferences.hostSourceIds.some((id) =>
|
||||
sameConnectionHost(id, failure.sourceId),
|
||||
) &&
|
||||
!hosts.some(
|
||||
(host) =>
|
||||
hostConnectionApproved(host) &&
|
||||
hostMatchesSource(failure.sourceId, host.hostId),
|
||||
)
|
||||
) && sourceConnectionIncomplete(hosts, failure.sourceId)
|
||||
: preferences.syncSourceIds.includes(failure.sourceId) &&
|
||||
!imports.sources.some(
|
||||
(source) =>
|
||||
@@ -754,14 +751,22 @@ export function ApplicationsHome({
|
||||
const autoFailureText = autoFailures
|
||||
.map((failure) =>
|
||||
failure.kind === "host"
|
||||
? t("apps.autoHostFailed", {
|
||||
name: nameOf(failure.sourceId),
|
||||
reason: failure.reason
|
||||
? t("apps.failureReason", {
|
||||
reason: localizeFailureReason(failure.reason),
|
||||
})
|
||||
: "",
|
||||
})
|
||||
? t(
|
||||
failure.profiles
|
||||
? "apps.autoHostProfilesFailed"
|
||||
: "apps.autoHostFailed",
|
||||
{
|
||||
name: nameOf(failure.sourceId),
|
||||
...(failure.profiles
|
||||
? { profiles: failure.profiles.join(", ") }
|
||||
: {}),
|
||||
reason: failure.reason
|
||||
? t("apps.failureReason", {
|
||||
reason: localizeFailureReason(failure.reason),
|
||||
})
|
||||
: "",
|
||||
},
|
||||
)
|
||||
: t("apps.autoSyncFailed", {
|
||||
name: nameOf(failure.sourceId),
|
||||
reason: localizeFailureReason(failure.reason),
|
||||
@@ -1181,8 +1186,9 @@ export function ApplicationsHome({
|
||||
t,
|
||||
callState === "unavailable",
|
||||
) || sourceSyncActivity(source.source_id, syncHealth, locale, t);
|
||||
const host = hosts.find((item) =>
|
||||
sourceMatchesHost(source.source_id, item.hostId),
|
||||
const recallAllowed = sourceConnectionApproved(
|
||||
hosts,
|
||||
source.source_id,
|
||||
);
|
||||
return (
|
||||
<div className="r6-home-app-row" key={source.source_id}>
|
||||
@@ -1193,8 +1199,7 @@ export function ApplicationsHome({
|
||||
<p>
|
||||
{source.sync_enabled
|
||||
? t("apps.syncEnabled")
|
||||
: host?.installed &&
|
||||
host.permission?.state === "approved"
|
||||
: recallAllowed
|
||||
? t("apps.recallAllowed")
|
||||
: source.authorized
|
||||
? t("apps.historyAllowed")
|
||||
|
||||
@@ -118,6 +118,14 @@
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.r6-app-profiles {
|
||||
font-size: 13px;
|
||||
color: #637786;
|
||||
line-height: 1.8;
|
||||
margin: 4px 0;
|
||||
padding-left: 18px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.r6-app-detail-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Mirrors desktop `SOURCE_HOST`. OpenClaw and DeepSeek Harness profiles
|
||||
* are not accepted by one-click insertion. */
|
||||
/** Mirrors desktop `SOURCE_HOST`. OpenClaw is not accepted by one-click
|
||||
* insertion; DeepSeek Harness has no single host and goes through
|
||||
* `PROFILE_SOURCES` instead. */
|
||||
export const SOURCE_HOST: Readonly<Record<string, string>> = {
|
||||
cursor: "cursor",
|
||||
"claude-code": "claude-code",
|
||||
@@ -14,6 +15,23 @@ export const SOURCE_HOST: Readonly<Record<string, string>> = {
|
||||
|
||||
export const ONE_CLICK_SOURCES = new Set(Object.keys(SOURCE_HOST));
|
||||
|
||||
/** Sources configured per profile. Each profile is its own host
|
||||
* (`deepseek-harness:<profile>`, with its own patch, credential and grant), so
|
||||
* the app's one switch covers every profile the desktop detected. */
|
||||
export const PROFILE_SOURCES: ReadonlySet<string> = new Set([
|
||||
"deepseek-harness",
|
||||
]);
|
||||
|
||||
export function isProfileSource(sourceId: string) {
|
||||
return PROFILE_SOURCES.has(sourceId);
|
||||
}
|
||||
|
||||
/** `deepseek-harness:web` -> `web`. */
|
||||
export function profileName(hostId: string) {
|
||||
const colon = hostId.indexOf(":");
|
||||
return colon < 0 ? hostId : hostId.slice(colon + 1);
|
||||
}
|
||||
|
||||
export function connectionHostId(sourceId: string): string | null {
|
||||
return SOURCE_HOST[sourceId] ?? null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { SourceResponse } from "@one-memory/contracts";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { connectionHostId, sameConnectionHost } from "./connection-hosts";
|
||||
import {
|
||||
connectionHostId,
|
||||
isProfileSource,
|
||||
sameConnectionHost,
|
||||
} from "./connection-hosts";
|
||||
import {
|
||||
readConnectionPreferences,
|
||||
writeConnectionPreference,
|
||||
@@ -8,11 +12,13 @@ import {
|
||||
import {
|
||||
cachedRecallHosts,
|
||||
enableHostConnection,
|
||||
hostConnectionApproved,
|
||||
hostConnectionUnsupported,
|
||||
hostWriteInFlight,
|
||||
hostWriteReason,
|
||||
matchHost,
|
||||
profileHosts,
|
||||
profileWriteFailures,
|
||||
sourceConnectionIncomplete,
|
||||
} from "./host-state";
|
||||
import { onClientReset, queryClient } from "./query-client";
|
||||
import { latestSource, sourceWriteInFlight } from "./source-state";
|
||||
@@ -37,6 +43,8 @@ export type AutoConnectFailure = {
|
||||
kind: "sync" | "host";
|
||||
sourceId: string;
|
||||
reason: string | null;
|
||||
/** Which profiles of a per-profile source it was; the others are kept. */
|
||||
profiles?: string[];
|
||||
};
|
||||
export type AutoConnectState = {
|
||||
running: string[];
|
||||
@@ -53,8 +61,10 @@ queryClient.setQueryDefaults(AUTO_KEY, {
|
||||
});
|
||||
|
||||
let flight: Promise<AutoConnectFailure[]> | null = null;
|
||||
const seenProfiles = new Map<string, Set<string>>();
|
||||
onClientReset(() => {
|
||||
flight = null;
|
||||
seenProfiles.clear();
|
||||
});
|
||||
|
||||
function autoState() {
|
||||
@@ -148,8 +158,26 @@ export async function reconcileConnectionPreferences(
|
||||
function hostNeedsApply(sourceId: string) {
|
||||
const hosts = cachedRecallHosts();
|
||||
if (hosts === undefined) return false;
|
||||
const host = matchHost(hosts, sourceId);
|
||||
return !hostConnectionApproved(host) && !hostConnectionUnsupported(host);
|
||||
return sourceConnectionIncomplete(hosts, sourceId);
|
||||
}
|
||||
|
||||
/** `attempted` holds one apply per source, which is right until a per-profile
|
||||
* source grows a profile: that one was never tried, so the remembered switch
|
||||
* gets one more apply. A profile that fails is not retried on every poll. */
|
||||
function rearmNewProfiles(sourceIds: string[]) {
|
||||
const hosts = cachedRecallHosts();
|
||||
if (hosts === undefined) return;
|
||||
for (const sourceId of sourceIds) {
|
||||
if (!isProfileSource(sourceId)) continue;
|
||||
const current = profileHosts(hosts, sourceId).map((host) => host.hostId);
|
||||
const seen = seenProfiles.get(sourceId);
|
||||
seenProfiles.set(sourceId, new Set([...(seen ?? []), ...current]));
|
||||
if (!seen || current.every((hostId) => seen.has(hostId))) continue;
|
||||
updateAuto((state) => ({
|
||||
...state,
|
||||
attempted: state.attempted.filter((key) => key !== hostKey(sourceId)),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a remembered host is still waiting for (or in) its one apply. */
|
||||
@@ -191,6 +219,7 @@ export function autoReconcile(
|
||||
if (hostConnectionUnsupported(matchHost(hosts, id)))
|
||||
writeConnectionPreference("host", id, false);
|
||||
const preferences = readConnectionPreferences();
|
||||
rearmNewProfiles(preferences.hostSourceIds);
|
||||
const { attempted } = autoState();
|
||||
const only = options.only;
|
||||
const wantedHosts = only
|
||||
@@ -256,6 +285,7 @@ export function retryAutoReconcile(options: ReconcileOptions) {
|
||||
|
||||
export function resetConnectionReconcile() {
|
||||
flight = null;
|
||||
seenProfiles.clear();
|
||||
queryClient.removeQueries({ queryKey: AUTO_KEY });
|
||||
}
|
||||
|
||||
@@ -301,12 +331,15 @@ async function runReconcile(
|
||||
// Per-app: other hosts keep going. Unsupported never succeeds on retry.
|
||||
if (error instanceof Error && error.message === "host_unsupported")
|
||||
writeConnectionPreference("host", sourceId, false);
|
||||
else
|
||||
else {
|
||||
const profiles = profileWriteFailures(error);
|
||||
failures.push({
|
||||
kind: "host",
|
||||
sourceId,
|
||||
reason: hostWriteReason(error),
|
||||
...(profiles ? { profiles } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/** A fake desktop bridge holding DeepSeek Harness profile hosts, for tests. */
|
||||
|
||||
import { vi } from "vitest";
|
||||
import type { RecallHostStatus, RecallPermissionStatus } from "./global";
|
||||
|
||||
type Bridge = NonNullable<typeof window.oneMemory>;
|
||||
type Grant =
|
||||
| "not-installed"
|
||||
| "install-only"
|
||||
| "approved"
|
||||
| "revoked"
|
||||
| "unsupported";
|
||||
|
||||
function permission(hostId: string, state: Grant): RecallPermissionStatus {
|
||||
return {
|
||||
hostId,
|
||||
state,
|
||||
reason: null,
|
||||
receipt:
|
||||
state === "not-installed" || state === "unsupported"
|
||||
? null
|
||||
: {
|
||||
hostId,
|
||||
installationId: `install-${hostId}`,
|
||||
revision: 1,
|
||||
status: state,
|
||||
allowedTools: state === "approved" ? ["om_recall"] : [],
|
||||
allowedScopes: state === "approved" ? ["all-apps"] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A desktop that knows a set of Harness profiles and keeps their state. */
|
||||
export function harnessBridge(
|
||||
initial: Record<string, Grant>,
|
||||
options: { failInstall?: string[]; failUninstall?: string[] } = {},
|
||||
) {
|
||||
const state = new Map(
|
||||
Object.entries(initial).map(([profile, grant]) => [
|
||||
`deepseek-harness:${profile}`,
|
||||
grant,
|
||||
]),
|
||||
);
|
||||
const hosts = (): RecallHostStatus[] =>
|
||||
[...state].map(([hostId, grant]) => ({
|
||||
hostId,
|
||||
label: `DeepSeek Harness · ${hostId.split(":")[1]}`,
|
||||
present: true,
|
||||
installed: grant === "install-only" || grant === "approved",
|
||||
permission: permission(hostId, grant),
|
||||
// Recorded by the desktop when the grant lands, as on a real install.
|
||||
selfCheck:
|
||||
grant === "approved"
|
||||
? { state: "wired", detail: "", probed_at: "2026-11-02" }
|
||||
: null,
|
||||
skillDrift: null,
|
||||
}));
|
||||
const failing = (hostId: string, list: string[] = []) =>
|
||||
list.some((profile) => hostId === `deepseek-harness:${profile}`);
|
||||
const api = {
|
||||
getRecallHosts: vi.fn(async () => hosts()),
|
||||
getRecallPermission: vi.fn(async (hostId: string) =>
|
||||
permission(hostId, state.get(hostId) ?? "not-installed"),
|
||||
),
|
||||
previewRecallInstall: vi.fn(async (hostId: string) => ({
|
||||
hostId,
|
||||
hostLabel: hostId,
|
||||
files: [],
|
||||
fingerprint: `fp-${hostId}`,
|
||||
})),
|
||||
installRecall: vi.fn(async (hostId: string, _approved?: string) => {
|
||||
if (failing(hostId, options.failInstall))
|
||||
throw Error(
|
||||
"Error invoking remote method 'one-memory:install-recall': Error: host_control_unavailable",
|
||||
);
|
||||
state.set(hostId, "install-only");
|
||||
return {
|
||||
hostId,
|
||||
installed: true,
|
||||
permission: permission(hostId, "install-only"),
|
||||
};
|
||||
}),
|
||||
setRecallPermission: vi.fn(async (hostId: string, _input: unknown) => {
|
||||
state.set(hostId, "approved");
|
||||
return permission(hostId, "approved");
|
||||
}),
|
||||
revokeRecallPermission: vi.fn(async (hostId: string, _input: unknown) => {
|
||||
state.set(hostId, "revoked");
|
||||
return permission(hostId, "revoked");
|
||||
}),
|
||||
previewRecallUninstall: vi.fn(async (hostId: string) => ({
|
||||
hostId,
|
||||
hostLabel: hostId,
|
||||
files: [],
|
||||
review: [],
|
||||
fingerprint: `fp-${hostId}`,
|
||||
})),
|
||||
uninstallRecall: vi.fn(async (hostId: string, _approved?: string) => {
|
||||
if (failing(hostId, options.failUninstall))
|
||||
throw Error(
|
||||
"Error invoking remote method 'one-memory:uninstall-recall': Error: host_control_unavailable",
|
||||
);
|
||||
state.set(hostId, "not-installed");
|
||||
return {
|
||||
hostId,
|
||||
installed: false,
|
||||
permission: permission(hostId, "not-installed"),
|
||||
};
|
||||
}),
|
||||
repairRecallCommands: vi.fn(async () => []),
|
||||
};
|
||||
window.oneMemory = api as unknown as Bridge;
|
||||
return {
|
||||
api,
|
||||
state,
|
||||
addProfile(profile: string) {
|
||||
state.set(`deepseek-harness:${profile}`, "not-installed");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
/**
|
||||
* DeepSeek Harness is configured per profile: every `deepseek-harness:<profile>`
|
||||
* is its own host with its own patch, credential and grant. The app page has
|
||||
* one switch for all of them (#230). These pin what that switch does: on
|
||||
* installs and grants every detected profile, off removes and revokes every
|
||||
* linked one, a failing profile is named without undoing the others, and a
|
||||
* profile that appears later is picked up by the remembered switch.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { saveConnectionPreferences } from "./connection-preferences";
|
||||
import {
|
||||
autoReconcile,
|
||||
resetConnectionReconcile,
|
||||
} from "./connection-reconcile";
|
||||
import { harnessBridge } from "./harness-profiles.test-fixture";
|
||||
import {
|
||||
disconnectProfiles,
|
||||
enableHostConnection,
|
||||
hostWriteReason,
|
||||
ProfileWriteError,
|
||||
profileHosts,
|
||||
refreshRecallHosts,
|
||||
sourceConnectionApproved,
|
||||
sourceConnectionIncomplete,
|
||||
} from "./host-state";
|
||||
|
||||
afterEach(() => {
|
||||
window.oneMemory = undefined;
|
||||
window.localStorage.clear();
|
||||
resetConnectionReconcile();
|
||||
});
|
||||
|
||||
describe("DeepSeek Harness profile hosts", () => {
|
||||
it("lists only the profiles of the per-profile source", async () => {
|
||||
harnessBridge({ desktop: "not-installed", web: "approved" });
|
||||
const hosts = await refreshRecallHosts();
|
||||
expect(
|
||||
profileHosts(hosts, "deepseek-harness").map((host) => host.hostId),
|
||||
).toEqual(["deepseek-harness:desktop", "deepseek-harness:web"]);
|
||||
expect(profileHosts(hosts, "cursor")).toEqual([]);
|
||||
// One approved profile reads as on; the other is still to do.
|
||||
expect(sourceConnectionApproved(hosts, "deepseek-harness")).toBe(true);
|
||||
expect(sourceConnectionIncomplete(hosts, "deepseek-harness")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("turning the DeepSeek Harness switch on", () => {
|
||||
it("installs and grants every profile that is not approved yet", async () => {
|
||||
const { api, state } = harnessBridge({
|
||||
desktop: "not-installed",
|
||||
headless: "revoked",
|
||||
web: "approved",
|
||||
});
|
||||
await refreshRecallHosts();
|
||||
await enableHostConnection("deepseek-harness");
|
||||
expect(api.installRecall.mock.calls.map(([hostId]) => hostId)).toEqual([
|
||||
"deepseek-harness:desktop",
|
||||
"deepseek-harness:headless",
|
||||
]);
|
||||
expect(api.setRecallPermission).toHaveBeenCalledWith(
|
||||
"deepseek-harness:desktop",
|
||||
expect.objectContaining({
|
||||
allowedTools: ["om_recall", "om_conversations", "om_diagnostic_trace"],
|
||||
allowedScopes: ["all-apps", "source:deepseek-harness"],
|
||||
userConfirmed: true,
|
||||
}),
|
||||
);
|
||||
expect([...state.values()]).toEqual(["approved", "approved", "approved"]);
|
||||
});
|
||||
|
||||
it("keeps the profiles that worked and names the one that failed", async () => {
|
||||
const { api, state } = harnessBridge(
|
||||
{ desktop: "not-installed", web: "not-installed" },
|
||||
{ failInstall: ["web"] },
|
||||
);
|
||||
await refreshRecallHosts();
|
||||
const error = await enableHostConnection("deepseek-harness").catch(
|
||||
(reason: unknown) => reason,
|
||||
);
|
||||
expect(error).toBeInstanceOf(ProfileWriteError);
|
||||
expect((error as ProfileWriteError).profiles).toEqual(["web"]);
|
||||
expect((error as Error).message).toContain("host_control_unavailable");
|
||||
expect(state.get("deepseek-harness:desktop")).toBe("approved");
|
||||
expect(state.get("deepseek-harness:web")).toBe("not-installed");
|
||||
expect(api.setRecallPermission).toHaveBeenCalledTimes(1);
|
||||
// The failure re-read lands the real state of every profile.
|
||||
const hosts = await refreshRecallHosts();
|
||||
expect(sourceConnectionApproved(hosts, "deepseek-harness")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("turning the DeepSeek Harness switch off", () => {
|
||||
it("removes and revokes every linked profile and leaves unlinked ones alone", async () => {
|
||||
const { api, state } = harnessBridge({
|
||||
desktop: "approved",
|
||||
headless: "not-installed",
|
||||
web: "approved",
|
||||
});
|
||||
await refreshRecallHosts();
|
||||
const results = await disconnectProfiles("deepseek-harness");
|
||||
expect(results.map((item) => [item.hostId, item.outcome.state])).toEqual([
|
||||
["deepseek-harness:desktop", "removed"],
|
||||
["deepseek-harness:web", "removed"],
|
||||
]);
|
||||
expect(api.uninstallRecall).toHaveBeenCalledTimes(2);
|
||||
expect(state.get("deepseek-harness:desktop")).toBe("not-installed");
|
||||
expect(state.get("deepseek-harness:web")).toBe("not-installed");
|
||||
});
|
||||
|
||||
it("revokes a profile whose files cannot be removed and still reaches the rest", async () => {
|
||||
const { api, state } = harnessBridge(
|
||||
{ desktop: "approved", web: "approved" },
|
||||
{ failUninstall: ["desktop"] },
|
||||
);
|
||||
await refreshRecallHosts();
|
||||
const results = await disconnectProfiles("deepseek-harness");
|
||||
expect(results.map((item) => item.outcome.state)).toEqual([
|
||||
"revoked",
|
||||
"removed",
|
||||
]);
|
||||
expect(api.revokeRecallPermission).toHaveBeenCalledWith(
|
||||
"deepseek-harness:desktop",
|
||||
expect.objectContaining({ userConfirmed: true }),
|
||||
);
|
||||
expect(state.get("deepseek-harness:desktop")).toBe("revoked");
|
||||
expect(state.get("deepseek-harness:web")).toBe("not-installed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a profile created after the switch was turned on", () => {
|
||||
it("is installed and granted by the remembered switch while the app is running", async () => {
|
||||
saveConnectionPreferences({
|
||||
syncSourceIds: [],
|
||||
hostSourceIds: ["deepseek-harness"],
|
||||
});
|
||||
const harness = harnessBridge({ desktop: "not-installed" });
|
||||
await refreshRecallHosts();
|
||||
expect(await autoReconcile({ sources: [] })).toEqual([]);
|
||||
expect(harness.api.installRecall).toHaveBeenCalledTimes(1);
|
||||
|
||||
// One attempt was already spent on this source; the new profile gets its own.
|
||||
harness.addProfile("web");
|
||||
await refreshRecallHosts();
|
||||
expect(await autoReconcile({ sources: [] })).toEqual([]);
|
||||
expect(harness.api.installRecall.mock.calls.at(-1)).toEqual([
|
||||
"deepseek-harness:web",
|
||||
"fp-deepseek-harness:web",
|
||||
]);
|
||||
expect(harness.state.get("deepseek-harness:web")).toBe("approved");
|
||||
});
|
||||
|
||||
it("does not retry a failing profile on every poll", async () => {
|
||||
saveConnectionPreferences({
|
||||
syncSourceIds: [],
|
||||
hostSourceIds: ["deepseek-harness"],
|
||||
});
|
||||
const harness = harnessBridge(
|
||||
{ desktop: "approved", web: "not-installed" },
|
||||
{ failInstall: ["web"] },
|
||||
);
|
||||
await refreshRecallHosts();
|
||||
expect(await autoReconcile({ sources: [] })).toEqual([
|
||||
{
|
||||
kind: "host",
|
||||
sourceId: "deepseek-harness",
|
||||
reason: "应用控制通道暂不可用",
|
||||
profiles: ["web"],
|
||||
},
|
||||
]);
|
||||
await refreshRecallHosts();
|
||||
expect(await autoReconcile({ sources: [] })).toBeNull();
|
||||
expect(harness.api.installRecall).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stays out while the switch is off", async () => {
|
||||
const harness = harnessBridge({ desktop: "not-installed" });
|
||||
await refreshRecallHosts();
|
||||
expect(await autoReconcile({ sources: [] })).toBeNull();
|
||||
harness.addProfile("web");
|
||||
await refreshRecallHosts();
|
||||
expect(await autoReconcile({ sources: [] })).toBeNull();
|
||||
expect(harness.api.installRecall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a profile whose name MemHub cannot grant", () => {
|
||||
it("is named with the real reason and does not hold up the others", async () => {
|
||||
const { state } = harnessBridge({
|
||||
My_Profile: "unsupported",
|
||||
web: "not-installed",
|
||||
});
|
||||
await refreshRecallHosts();
|
||||
const error = await enableHostConnection("deepseek-harness").catch(
|
||||
(reason: unknown) => reason,
|
||||
);
|
||||
expect((error as ProfileWriteError).profiles).toEqual(["My_Profile"]);
|
||||
expect(hostWriteReason(error)).toBe("appProfileNameUnsupported");
|
||||
expect(state.get("deepseek-harness:web")).toBe("approved");
|
||||
// Nothing is left for the remembered switch to keep trying.
|
||||
expect(
|
||||
sourceConnectionIncomplete(
|
||||
await refreshRecallHosts(),
|
||||
"deepseek-harness",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
import { MutationObserver, useQuery } from "@tanstack/react-query";
|
||||
import { connectionHostId, hostMatchesSource } from "./connection-hosts";
|
||||
import {
|
||||
connectionHostId,
|
||||
hostMatchesSource,
|
||||
isProfileSource,
|
||||
profileName,
|
||||
} from "./connection-hosts";
|
||||
import type {
|
||||
RecallHostStatus,
|
||||
RecallPermissionStatus,
|
||||
@@ -226,6 +231,7 @@ export function matchHost(
|
||||
|
||||
const REASONS: Record<string, string> = {
|
||||
host_unsupported: "它没有 MCP 通道,无法发放可撤销的记忆凭证",
|
||||
profile_name_unsupported: "appProfileNameUnsupported",
|
||||
unsupported_host: "当前安装不支持一键插入",
|
||||
connection_client_unavailable: "桌面端的连接组件不可用",
|
||||
noncanonical_data_dir_host_integration_disabled: "测试实例不接入应用",
|
||||
@@ -350,18 +356,77 @@ export function hostWriteInFlight() {
|
||||
return queryClient.isMutating({ mutationKey: HOST_WRITE }) > 0;
|
||||
}
|
||||
|
||||
/** A per-profile source writes to every profile, so it waits on each of them. */
|
||||
function sourceHost(sourceId: string) {
|
||||
if (isProfileSource(sourceId))
|
||||
return [
|
||||
sourceId,
|
||||
...profileHosts(cachedRecallHosts(), sourceId).map((host) => host.hostId),
|
||||
];
|
||||
return [connectionHostId(sourceId) ?? sourceId];
|
||||
}
|
||||
|
||||
/** Every detected profile host of a per-profile source, e.g. DeepSeek Harness. */
|
||||
export function profileHosts(
|
||||
hosts: readonly RecallHostStatus[] | undefined,
|
||||
sourceId: string,
|
||||
) {
|
||||
if (!isProfileSource(sourceId)) return [];
|
||||
return (hosts ?? []).filter((host) => host.hostId.startsWith(`${sourceId}:`));
|
||||
}
|
||||
|
||||
/** The app switch reads as on: its host, or for a per-profile source any
|
||||
* profile, may use memory. A failed profile is reported, not hidden. */
|
||||
export function sourceConnectionApproved(
|
||||
hosts: readonly RecallHostStatus[] | undefined,
|
||||
sourceId: string,
|
||||
) {
|
||||
if (isProfileSource(sourceId))
|
||||
return profileHosts(hosts, sourceId).some(hostConnectionApproved);
|
||||
return hostConnectionApproved(matchHost(hosts, sourceId));
|
||||
}
|
||||
|
||||
/** Something the switch would still do: a host, or any profile, that is
|
||||
* neither approved nor unsupported. */
|
||||
export function sourceConnectionIncomplete(
|
||||
hosts: readonly RecallHostStatus[] | undefined,
|
||||
sourceId: string,
|
||||
) {
|
||||
const pending = (host: RecallHostStatus | null) =>
|
||||
!hostConnectionApproved(host) && !hostConnectionUnsupported(host);
|
||||
if (isProfileSource(sourceId))
|
||||
return profileHosts(hosts, sourceId).some(pending);
|
||||
return pending(matchHost(hosts, sourceId));
|
||||
}
|
||||
|
||||
/** Some profiles of a per-profile source could not be written; the others
|
||||
* were kept. The message keeps the first cause so `hostWriteReason` names it. */
|
||||
export class ProfileWriteError extends Error {
|
||||
readonly profiles: string[];
|
||||
constructor(profiles: string[], cause: unknown) {
|
||||
super(
|
||||
`profile_write_failed ${cause instanceof Error ? cause.message : ""}`.trim(),
|
||||
);
|
||||
this.profiles = profiles;
|
||||
}
|
||||
}
|
||||
|
||||
/** The profile names a failed write left behind, if it was a profile write. */
|
||||
export function profileWriteFailures(error: unknown): string[] | null {
|
||||
return error instanceof ProfileWriteError ? error.profiles : null;
|
||||
}
|
||||
|
||||
type Connected = {
|
||||
hostId: string;
|
||||
installed: boolean;
|
||||
permission?: RecallPermissionStatus;
|
||||
};
|
||||
|
||||
async function connect(sourceId: string): Promise<Connected | null> {
|
||||
const hostId = connectionHostId(sourceId);
|
||||
type Bridge = NonNullable<ReturnType<typeof bridge>>;
|
||||
|
||||
async function connect(sourceId: string): Promise<Connected[]> {
|
||||
const profiles = isProfileSource(sourceId);
|
||||
const hostId = profiles ? sourceId : connectionHostId(sourceId);
|
||||
const api = bridge();
|
||||
if (
|
||||
!hostId ||
|
||||
@@ -371,12 +436,65 @@ async function connect(sourceId: string): Promise<Connected | null> {
|
||||
!api.setRecallPermission
|
||||
)
|
||||
throw Error("unsupported_host");
|
||||
const existing = matchHost(await refreshRecallHosts(), sourceId);
|
||||
if (hostConnectionUnsupported(existing)) throw Error("host_unsupported");
|
||||
if (hostConnectionApproved(existing)) {
|
||||
await api.repairRecallCommands?.();
|
||||
return null;
|
||||
const hosts = await refreshRecallHosts();
|
||||
if (profiles) return connectProfiles(api, sourceId, hosts);
|
||||
const result = await grantHost(
|
||||
api,
|
||||
hostId,
|
||||
sourceId,
|
||||
matchHost(hosts, sourceId),
|
||||
);
|
||||
await api.repairRecallCommands?.();
|
||||
return result ? [result] : [];
|
||||
}
|
||||
|
||||
/** One switch, every detected profile. A profile that fails does not undo
|
||||
* the ones that worked; the error names it. Profiles already approved are
|
||||
* left alone, so turning the switch on again only reaches what is missing. */
|
||||
async function connectProfiles(
|
||||
api: Bridge,
|
||||
sourceId: string,
|
||||
hosts: RecallHostStatus[],
|
||||
): Promise<Connected[]> {
|
||||
const profiles = profileHosts(hosts, sourceId);
|
||||
if (!profiles.length) throw Error("profiles_not_found");
|
||||
const connected: Connected[] = [];
|
||||
const failed: string[] = [];
|
||||
let cause: unknown;
|
||||
for (const host of profiles) {
|
||||
try {
|
||||
const result = await grantHost(api, host.hostId, sourceId, host);
|
||||
if (result) connected.push(result);
|
||||
} catch (error) {
|
||||
failed.push(profileName(host.hostId));
|
||||
// Harness speaks MCP, so "unsupported" can only mean the id grammar
|
||||
// refused this name; "no MCP channel" would send the person astray.
|
||||
cause ??= hostConnectionUnsupported(host)
|
||||
? Error("profile_name_unsupported")
|
||||
: error;
|
||||
}
|
||||
}
|
||||
if (failed.length < profiles.length) await api.repairRecallCommands?.();
|
||||
if (failed.length) throw new ProfileWriteError(failed, cause);
|
||||
return connected;
|
||||
}
|
||||
|
||||
/** Installs and grants one host; null when it was already approved. */
|
||||
async function grantHost(
|
||||
api: Bridge,
|
||||
hostId: string,
|
||||
sourceId: string,
|
||||
existing: RecallHostStatus | null,
|
||||
): Promise<Connected | null> {
|
||||
if (
|
||||
!api.previewRecallInstall ||
|
||||
!api.installRecall ||
|
||||
!api.getRecallPermission ||
|
||||
!api.setRecallPermission
|
||||
)
|
||||
throw Error("unsupported_host");
|
||||
if (hostConnectionUnsupported(existing)) throw Error("host_unsupported");
|
||||
if (hostConnectionApproved(existing)) return null;
|
||||
const preview = await api.previewRecallInstall(hostId);
|
||||
const installed = await api.installRecall(hostId, preview.fingerprint);
|
||||
let permission =
|
||||
@@ -394,19 +512,19 @@ async function connect(sourceId: string): Promise<Connected | null> {
|
||||
}
|
||||
if (permission.receipt?.status !== "approved")
|
||||
throw Error("permission_not_approved");
|
||||
await api.repairRecallCommands?.();
|
||||
return { hostId, installed: true, permission };
|
||||
}
|
||||
|
||||
function landConnected(result: Connected | null) {
|
||||
if (result)
|
||||
function landConnected(results: Connected[]) {
|
||||
for (const result of results)
|
||||
landHost(result.hostId, {
|
||||
installed: result.installed,
|
||||
permission: result.permission,
|
||||
});
|
||||
}
|
||||
|
||||
/** Installs and grants one host; resolves only once the grant is approved. */
|
||||
/** Installs and grants one host (or every profile of a per-profile source);
|
||||
* resolves only once the grants are approved. */
|
||||
export const enableHostConnection = hostWrite(
|
||||
sourceHost,
|
||||
connect,
|
||||
@@ -479,6 +597,22 @@ async function disconnect(hostId: string): Promise<Disconnected> {
|
||||
}
|
||||
}
|
||||
|
||||
function landDisconnected({
|
||||
hostId,
|
||||
outcome,
|
||||
}: {
|
||||
hostId: string;
|
||||
outcome: DisconnectOutcome;
|
||||
}) {
|
||||
if (outcome.state === "removed")
|
||||
landHost(hostId, {
|
||||
installed: false,
|
||||
...(outcome.permission ? { permission: outcome.permission } : {}),
|
||||
});
|
||||
else if (outcome.state !== "failed" && outcome.permission)
|
||||
landHost(hostId, { permission: outcome.permission });
|
||||
}
|
||||
|
||||
export const disconnectHost = hostWrite(
|
||||
(hostId: string) => [hostId],
|
||||
async (
|
||||
@@ -489,14 +623,48 @@ export const disconnectHost = hostWrite(
|
||||
const rereadFailed = !(await rereadOnce());
|
||||
return { hostId, outcome: { ...result, rereadFailed } };
|
||||
},
|
||||
({ hostId, outcome }) => {
|
||||
if (outcome.state === "removed")
|
||||
landHost(hostId, {
|
||||
installed: false,
|
||||
...(outcome.permission ? { permission: outcome.permission } : {}),
|
||||
landDisconnected,
|
||||
);
|
||||
|
||||
export type ProfileDisconnect = { hostId: string; outcome: DisconnectOutcome };
|
||||
|
||||
/** Linked to MemHub at all: installed, or holding a receipt that is not yet
|
||||
* revoked. A profile that never was has nothing to remove. */
|
||||
function profileLinked(host: RecallHostStatus) {
|
||||
const status = host.permission?.receipt?.status;
|
||||
return host.installed || (status !== undefined && status !== "revoked");
|
||||
}
|
||||
|
||||
/** Turn a per-profile source off: each linked profile is removed and its grant
|
||||
* revoked, exactly as one host would be. One failure does not stop the rest;
|
||||
* every outcome is returned so the page can name the profile it concerns. */
|
||||
export const disconnectProfiles = hostWrite(
|
||||
sourceHost,
|
||||
async (sourceId: string): Promise<ProfileDisconnect[]> => {
|
||||
const profiles = profileHosts(await refreshRecallHosts(), sourceId).filter(
|
||||
profileLinked,
|
||||
);
|
||||
const results: ProfileDisconnect[] = [];
|
||||
let anyFailed = false;
|
||||
for (const host of profiles) {
|
||||
const outcome = await disconnect(host.hostId);
|
||||
anyFailed ||= outcome.state === "failed";
|
||||
results.push({
|
||||
hostId: host.hostId,
|
||||
outcome:
|
||||
outcome.state === "failed"
|
||||
? { ...outcome, rereadFailed: false }
|
||||
: outcome,
|
||||
});
|
||||
else if (outcome.state !== "failed" && outcome.permission)
|
||||
landHost(hostId, { permission: outcome.permission });
|
||||
}
|
||||
if (anyFailed && !(await rereadOnce()))
|
||||
for (const result of results)
|
||||
if (result.outcome.state === "failed")
|
||||
result.outcome = { ...result.outcome, rereadFailed: true };
|
||||
return results;
|
||||
},
|
||||
(results) => {
|
||||
for (const result of results) landDisconnected(result);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -91,7 +91,14 @@ describe("single state layer", () => {
|
||||
});
|
||||
|
||||
it("reads and writes host connections only through host-state", () => {
|
||||
expect(offenders(hostUses, ["host-state.ts", "global.d.ts"])).toEqual([]);
|
||||
expect(
|
||||
offenders(hostUses, [
|
||||
"host-state.ts",
|
||||
"global.d.ts",
|
||||
// Test support: it is the fake bridge, and only tests import it.
|
||||
"harness-profiles.test-fixture.ts",
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads and writes sources only through source-state", () => {
|
||||
|
||||
@@ -40,7 +40,7 @@ Preview 不会弹出给用户;开关点击本身被视为确认。关闭时通
|
||||
| [Qwen Code](qwen-code.md) | 开放 | MCP + Skill | 无自动 Hook | `$QWEN_HOME/projects` 扫描 |
|
||||
| [Pi](pi.md) | 开放 | CLI Skill,不使用 MCP | JS Capture Extension | Pi session 扫描 + Extension 实时入口 |
|
||||
| [OpenClaw](openclaw.md) | 正式产品冻结 | 当前没有;仅有未开放的底层 MCP 写入器 | 没有 Hook、Plugin 或 Prefetch | Adapter 也被冻结,开关不可用 |
|
||||
| [DeepSeek Harness](deepseek-harness.md) | 详情页排除,来源管理页按 Profile 接入 | Profile 级 MCP Patch | 无自动 Hook | `$DSH_HOME/sessions` 扫描 |
|
||||
| [DeepSeek Harness](deepseek-harness.md) | 开放;一个开关覆盖全部已检测 Profile | Profile 级 MCP Patch | 无自动 Hook | `$DSH_HOME/sessions` 扫描 |
|
||||
|
||||
## 同步开关的统一精确语义
|
||||
|
||||
|
||||
@@ -1,28 +1,45 @@
|
||||
# DeepSeek Harness 一键插入支持
|
||||
|
||||
更新日期:2026-10-18
|
||||
更新日期:2026-11-02
|
||||
|
||||
## 结论
|
||||
|
||||
DeepSeek Harness 的 Profile 级 MCP 安装器已经实现,Profile Scoped Host ID(`deepseek-harness:<profile>`)在权限层按不透明标签处理,可正常走完 Preview 与安装。
|
||||
DeepSeek Harness 按 Profile 配置 MCP:每个 Profile 是一个独立 Host(`deepseek-harness:<profile>`),有自己的 `cordis.patch.yml`、Credential 与 Grant。应用详情页只有一个“一键插入应用”开关,它一次覆盖桌面检测到的全部 Profile:打开时逐个安装并授权,关闭时逐个移除并撤销。
|
||||
|
||||
## 接入方式和产品状态
|
||||
|
||||
| 项目 | DeepSeek Harness 当前实现 |
|
||||
|---|---|
|
||||
| 应用详情页一键插入 | 有意排除,因为无法选择 Profile |
|
||||
| 来源管理页 Profile 安装 | 可用;每个 Profile 独立 Patch、Credential 与 Grant |
|
||||
| 应用详情页一键插入 | 可用;一个开关覆盖全部已检测 Profile,并列出每个 Profile 的状态 |
|
||||
| 首次导入批量连接 | 排除;在应用详情页打开开关 |
|
||||
| Harness 查询 MemHub | 通过 Profile 级 Cordis MCP Client |
|
||||
| Skill | 没有 |
|
||||
| Prompt 前自动 Prefetch | 没有 |
|
||||
| Capture Hook/Plugin | 没有 |
|
||||
| Harness 新会话进入 MemHub | Core 扫描 `$DSH_HOME/sessions` |
|
||||
| Harness 新会话进入 MemHub | Core 扫描 `$DSH_HOME/sessions`,按 `session[.vN].jsonl[.zstd]` 识别会话文件 |
|
||||
|
||||
会话文件按写入时的格式版本命名:首版为 `session.jsonl` / `session.jsonl.zstd`,v3/v4 起为 `session.v3.jsonl.zstd` / `session.v4.jsonl.zstd`。来源扫描接受带版本号的名称;解析前再读文件头 `version` 字段,只导入已验证的 v0/v3/v4,未来未知版本不会被解析:整个文件以 `unsupported_schema` 报告,且不留下读取进度,将来支持该版本的发布会从头读取(错误导入比暂时漏导更难回滚)。`tool/result` 的输出无论写成 `tool-result` 块还是纯 `text` 块,都按同一条裁剪与凭据清洗路径导入。
|
||||
|
||||
## 两个开关分别做什么
|
||||
|
||||
### “一键插入应用”
|
||||
### 打开“一键插入应用”
|
||||
|
||||
应用详情页不可用。来源管理页对每个 Profile 写 `cordis.patch.yml`、创建独立 Credential 并授权;Host ID 形如 `deepseek-harness:<profile>`,冒号只作为标签存在,权限层的安装目录按 Host ID 摘要生成,不参与路径拼接。
|
||||
应用详情页对每个已检测、尚未获批的 Profile:
|
||||
|
||||
1. 预览并写入该 Profile 的 `cordis.patch.yml` 受管块,创建独立 Credential;
|
||||
2. 授予 `om_recall`、`om_conversations`、`om_diagnostic_trace`,范围为 `all-apps` 与 `source:deepseek-harness`(与其他应用的一键插入相同)。
|
||||
|
||||
已获批的 Profile 不会重装。某个 Profile 失败时,其他 Profile 的结果保留,页面提示失败的 Profile 名称;开关只要有一个 Profile 获批就显示为开。Host ID 里的冒号只作为标签存在,权限层的安装目录按 Host ID 摘要生成,不参与路径拼接。
|
||||
|
||||
### 关闭“一键插入应用”
|
||||
|
||||
对每个已接入的 Profile 先撤销 Credential,再删除受管块(与其他应用的关闭语义相同)。受管块被改过、无法删除时仍会撤销授权,页面列出需要核对的 Profile;撤销也失败的 Profile 会单独列出,它仍可使用记忆。从未接入的 Profile 不受影响。
|
||||
|
||||
### 之后新建的 Profile
|
||||
|
||||
桌面每次刷新应用接入状态时都会重新枚举 Profile(`$DSH_HOME/profiles/<profile>/cordis.yml`),应用首页打开时每 3 秒刷新一次;常驻的 Runtime 收到未知的 Profile Host ID 时也会重新读盘。开关的选择会被记住:开关为开时,新出现的 Profile 由应用首页自动安装并授权,不需要重启 MemHub。
|
||||
|
||||
每个新 Profile 触发一次自动尝试;失败时应用首页会指明 Profile,不会每次轮询都重试,下次启动或点击重试时再试一次。开关为关时不会自动接入任何 Profile。
|
||||
|
||||
### 打开“同步开关”
|
||||
|
||||
@@ -32,12 +49,10 @@ DeepSeek Harness 的 Profile 级 MCP 安装器已经实现,Profile Scoped Host
|
||||
|
||||
Core 停止扫描和 pending Job claim,running Job可结束,已有 Memory 保留。它不会修改任何 Profile 的 `cordis.patch.yml` 或 Credential。
|
||||
|
||||
### 修复一键安装后的组合语义
|
||||
### 组合语义
|
||||
|
||||
未来修复 Profile Host ID 后:
|
||||
|
||||
- 某个 Profile 的一键插入只决定该 Profile 是否能查询 MemHub;
|
||||
- 全局同步开关决定 MemHub 是否继续读取整个 DeepSeek Harness 的 Session;
|
||||
- 一键插入决定已检测 Profile 是否能查询 MemHub;
|
||||
- 同步开关决定 MemHub 是否继续读取整个 DeepSeek Harness 的 Session;
|
||||
- 没有自动 Prefetch,也没有 Capture Hook。
|
||||
|
||||
## Profile 粒度
|
||||
@@ -58,7 +73,7 @@ deepseek-harness:headless
|
||||
...
|
||||
```
|
||||
|
||||
理论上每个 Profile 拥有独立 Patch、Journal、Credential 和 Grant;Core 会话同步仍按统一的 `deepseek-harness` 来源扫描 `$DSH_HOME/sessions`。
|
||||
每个 Profile 拥有独立 Patch、Journal、Credential 和 Grant;Core 会话同步仍按统一的 `deepseek-harness` 来源扫描 `$DSH_HOME/sessions`。
|
||||
|
||||
## MCP Patch
|
||||
|
||||
@@ -85,13 +100,15 @@ DeepSeek Harness 不安装 Skill,也不安装 Capture Hook;新会话进入 M
|
||||
|
||||
## UI 状态
|
||||
|
||||
- 应用详情页排除 DeepSeek Harness,因为单一来源页无法选择 Profile。
|
||||
- 首次导入批量连接也排除它。
|
||||
- 来源管理页会列出每个 Profile,并显示独立安装按钮。
|
||||
- 应用详情页的“一键插入应用”下方列出每个已检测 Profile 及其“已接入 / 未接入”状态;没有检测到 Profile 时开关不可用,并提示创建 Profile 后回到此页。
|
||||
- Host ID 只接受小写字母、数字和连字符(最长 32 个字符)。名称不符合的 Profile 仍会列出,但无法授权,页面会指明它和原因,不影响其他 Profile。
|
||||
- 连接状态取各已接入 Profile 记录的自检结果:全部接通才显示“已接通”。
|
||||
- 首次导入批量连接排除 DeepSeek Harness。
|
||||
- CLI 的 `memhub host install deepseek-harness:<profile>` 只安装,不授权(状态停在 `install-only`);授权入口只有应用详情页的开关,CLI 没有授权命令。
|
||||
|
||||
## 已修复:Profile Host ID 校验
|
||||
|
||||
此前来源管理页安装会在写入 `cordis.patch.yml` 和创建 Credential 之前失败,原因是:
|
||||
此前 Profile 安装会在写入 `cordis.patch.yml` 和创建 Credential 之前失败,原因是:
|
||||
|
||||
```text
|
||||
系统生成 hostId = deepseek-harness:<profile>
|
||||
@@ -101,18 +118,12 @@ HostGrantStore 也不接受冒号
|
||||
|
||||
Host ID 是不可解释的标签:安装目录对它取摘要,不会把它拼进路径。现在权限层与 Grant 存储都接受一个可选的 `:<profile>` 段,因此 Profile 安装可以正常走完。这不是 DeepSeek Harness 不支持 MCP 的问题,而是 Profile Host ID 格式与权限校验不一致,现已对齐。
|
||||
|
||||
## 修复后的权限语义风险
|
||||
## 已修复:应用详情页返回 `unsupported_host`
|
||||
|
||||
来源管理页当前默认授权选择可能包含:
|
||||
|
||||
- `om_recall`、`om_conversations`、`om_diagnostic_trace`(`om_ask` 已随 `MemHub_ask` 移除,旧授权仍可读取但不再授权任何工具);
|
||||
- `all-apps`;
|
||||
- 所有已授权 Source 和 Workspace。
|
||||
|
||||
这比应用详情页通用授权更宽。Host ID 修复后仍应确认是否希望每个 Profile 默认获得如此宽的权限。
|
||||
此前应用详情页按“一个来源对应一个 Host”接入,而 DeepSeek Harness 没有不带 Profile 的 Host,开关因此置灰并返回 `unsupported_host`,安装后的 Profile 也只能停在 `install-only`(#230)。现在开关直接作用于全部 `deepseek-harness:<profile>` Host,复用与其他应用相同的安装、授权、撤销与卸载接口。
|
||||
|
||||
## 重复安装与卸载
|
||||
|
||||
底层 Patch 安装是幂等的:已有受管块时替换,不追加第二份。相同 Row ID 出现在 MemHub 哨兵块外时默认拒绝接管。
|
||||
|
||||
正常卸载设计是先撤销该 Profile Credential,再删除受管块并保留其他 Patch;如果删完没有其他 Row,恢复 `[]`。用户修改过受管块时不强删。
|
||||
卸载先撤销该 Profile Credential,再删除受管块并保留其他 Patch;如果删完没有其他 Row,恢复 `[]`。用户修改过受管块时不强删。
|
||||
@@ -545,6 +545,7 @@ const baseEnglishMessages = {
|
||||
nativeDevelopmentUnavailable: " (unavailable in development)",
|
||||
nativeOpen: "Open {product}",
|
||||
nativeQuit: "Quit {product} (background service keeps running)",
|
||||
nativeQuitStopsService: "Quit {product} (also stops the memory service)",
|
||||
nativeLoginError: "Could not change the login startup setting. Please retry.",
|
||||
nativeMoveTitle: "Move MemHub to Applications first",
|
||||
nativeMoveDetail:
|
||||
@@ -926,6 +927,23 @@ const baseEnglishMessages = {
|
||||
"Memory access was revoked, so {name} can no longer look up memories. Full removal could not be confirmed; review these paths manually: {paths}.",
|
||||
appRemovalReview:
|
||||
"Full connection removal could not be confirmed. Review these paths manually: {paths}.",
|
||||
appProfilesLabel: "{name} profiles",
|
||||
appProfileOn: "{profile}: connected",
|
||||
appProfileOff: "{profile}: not connected",
|
||||
appProfilesHint:
|
||||
"The switch installs the memory connection and grants access for every detected profile, and revokes them all when turned off. While it is on, new profiles are connected automatically.",
|
||||
appProfilesNone:
|
||||
"No {name} profile was detected. Create a profile, then come back to this page.",
|
||||
appProfilesConnectFailed:
|
||||
"These profiles could not be connected: {profiles}. The other profiles were kept. {reason}",
|
||||
appProfilesDisconnectFailed:
|
||||
"These profiles could not be disconnected and can still use memories: {profiles}. Please retry. {reason}",
|
||||
appProfilesRevokedRetained:
|
||||
"Memory access was revoked for these profiles, but not all of their connection files were removed; review them: {profiles}.",
|
||||
appProfileNameUnsupported:
|
||||
"profile names may only use lowercase letters, digits and hyphens, up to 32 characters",
|
||||
"apps.autoHostProfilesFailed":
|
||||
"{name} could not connect these profiles automatically ({profiles}){reason}.",
|
||||
"Cursor 的连接凭证已更新。请完全退出并重新打开 Cursor,再开始新对话。":
|
||||
"Cursor's connection credential changed. Fully quit and reopen Cursor before starting a new conversation.",
|
||||
连接其他工具: "Connect other tools",
|
||||
|
||||
@@ -13,6 +13,7 @@ export const recoveryChineseMessages = {
|
||||
nativeDevelopmentUnavailable: "(开发模式不可用)",
|
||||
nativeOpen: "打开 {product}",
|
||||
nativeQuit: "退出 {product}(后台服务继续运行)",
|
||||
nativeQuitStopsService: "退出 {product}(同时停止记忆服务)",
|
||||
nativeLoginError: "无法更改登录启动设置,请重试。",
|
||||
nativeMoveTitle: "请先将 MemHub 拖到“应用程序”文件夹",
|
||||
nativeMoveDetail:
|
||||
@@ -115,6 +116,8 @@ export const recoverySpanishMessages: Record<
|
||||
nativeDevelopmentUnavailable: " (no disponible en desarrollo)",
|
||||
nativeOpen: "Abrir {product}",
|
||||
nativeQuit: "Salir de {product} (el servicio sigue en segundo plano)",
|
||||
nativeQuitStopsService:
|
||||
"Salir de {product} (también detiene el servicio de memoria)",
|
||||
nativeLoginError:
|
||||
"No se pudo cambiar el inicio de sesión. Inténtalo de nuevo.",
|
||||
nativeMoveTitle: "Arrastra MemHub a la carpeta Aplicaciones primero",
|
||||
|
||||
@@ -298,6 +298,23 @@ export const settingsSpanishMessages = {
|
||||
"Se revocó el permiso; {name} ya no puede consultar memorias. No se pudo confirmar la eliminación completa de la conexión. Comprueba manualmente: {paths}.",
|
||||
appRemovalReview:
|
||||
"No se pudo confirmar la eliminación completa de la conexión. Comprueba manualmente: {paths}.",
|
||||
appProfilesLabel: "Perfiles de {name}",
|
||||
appProfileOn: "{profile}: conectado",
|
||||
appProfileOff: "{profile}: sin conectar",
|
||||
appProfilesHint:
|
||||
"El interruptor instala la conexión de memorias y concede acceso a cada perfil detectado, y los revoca todos al apagarlo. Mientras esté activado, los perfiles nuevos se conectan automáticamente.",
|
||||
appProfilesNone:
|
||||
"No se detectó ningún perfil de {name}. Crea un perfil y vuelve a esta página.",
|
||||
appProfilesConnectFailed:
|
||||
"No se pudieron conectar estos perfiles: {profiles}. Los demás perfiles se mantienen.{reason}",
|
||||
appProfilesDisconnectFailed:
|
||||
"No se pudieron desconectar estos perfiles y aún pueden usar memorias: {profiles}. Reintenta.{reason}",
|
||||
appProfilesRevokedRetained:
|
||||
"Se revocó el acceso a memorias de estos perfiles, pero no se eliminaron todos sus archivos de conexión; revísalos: {profiles}.",
|
||||
appProfileNameUnsupported:
|
||||
"los nombres de perfil solo admiten minúsculas, dígitos y guiones, hasta 32 caracteres",
|
||||
"apps.autoHostProfilesFailed":
|
||||
"No se pudieron conectar automáticamente estos perfiles de {name} ({profiles}){reason}. ",
|
||||
"Cursor 的连接凭证已更新。请完全退出并重新打开 Cursor,再开始新对话。":
|
||||
"Las credenciales de conexión de Cursor se actualizaron. Cierra completamente Cursor y vuelve a abrirlo; después, inicia una conversación nueva.",
|
||||
连接其他工具: "Conectar otras herramientas",
|
||||
@@ -350,6 +367,23 @@ const chineseOverrides: Partial<
|
||||
appRevokedReview:
|
||||
"已撤销记忆权限,{name} 已查不到记忆。但无法确认连接已全部移除,需要你手动核对:{paths}。",
|
||||
appRemovalReview: "无法确认连接已全部移除,需要你手动核对:{paths}。",
|
||||
appProfilesLabel: "{name} 的 Profile",
|
||||
appProfileOn: "{profile}:已接入",
|
||||
appProfileOff: "{profile}:未接入",
|
||||
appProfilesHint:
|
||||
"开关会为每个已检测到的 Profile 安装记忆连接并授权,关闭时全部撤销。开关开启期间,新建的 Profile 会自动接入。",
|
||||
appProfilesNone:
|
||||
"还没有检测到 {name} 的 Profile。创建 Profile 后再回到此页。",
|
||||
appProfilesConnectFailed:
|
||||
"以下 Profile 未能接入:{profiles}。其他 Profile 已保留。{reason}",
|
||||
appProfilesDisconnectFailed:
|
||||
"以下 Profile 的连接未能移除,记忆权限也未能撤销,仍可使用记忆:{profiles}。请重试。{reason}",
|
||||
appProfilesRevokedRetained:
|
||||
"已撤销以下 Profile 的记忆权限,但连接文件没有全部移除,请核对:{profiles}。",
|
||||
appProfileNameUnsupported:
|
||||
"Profile 名称只能包含小写字母、数字和连字符,最长 32 个字符",
|
||||
"apps.autoHostProfilesFailed":
|
||||
"{name} 的以下 Profile 未能自动接入({profiles}){reason}。",
|
||||
};
|
||||
export const settingsChineseMessages = Object.fromEntries(
|
||||
Object.keys(settingsSpanishMessages).map((key) => [
|
||||
|
||||
@@ -713,6 +713,8 @@ export const germanMessages: Record<MessageKey, string> = {
|
||||
nativeDevelopmentUnavailable: " (in Entwicklung nicht verfügbar)",
|
||||
nativeOpen: "{product} öffnen",
|
||||
nativeQuit: "{product} beenden (Hintergrunddienst läuft weiter)",
|
||||
nativeQuitStopsService:
|
||||
"{product} beenden (Gedächtnisdienst wird ebenfalls beendet)",
|
||||
nativeLoginError:
|
||||
"Starteinstellung bei Anmeldung konnte nicht geändert werden. Bitte wiederholen Sie den Vorgang.",
|
||||
nativeMoveTitle: "Verschieben Sie MemHub zuerst nach „Programme“",
|
||||
@@ -1103,6 +1105,23 @@ export const germanMessages: Record<MessageKey, string> = {
|
||||
"Der Erinnerungszugriff wurde widerrufen; {name} kann keine Erinnerungen mehr abfragen. Die vollständige Entfernung konnte nicht bestätigt werden; prüfen Sie diese Pfade manuell: {paths}.",
|
||||
appRemovalReview:
|
||||
"Vollständige Entfernung der Verbindung konnte nicht bestätigt werden. Prüfen Sie diese Pfade manuell: {paths}.",
|
||||
appProfilesLabel: "Profile von {name}",
|
||||
appProfileOn: "{profile}: verbunden",
|
||||
appProfileOff: "{profile}: nicht verbunden",
|
||||
appProfilesHint:
|
||||
"Der Schalter installiert für jedes erkannte Profil die Erinnerungsverbindung und erteilt den Zugriff; beim Ausschalten wird er für alle widerrufen. Solange er eingeschaltet ist, werden neue Profile automatisch verbunden.",
|
||||
appProfilesNone:
|
||||
"Es wurde kein Profil von {name} erkannt. Legen Sie ein Profil an und kehren Sie dann zu dieser Seite zurück.",
|
||||
appProfilesConnectFailed:
|
||||
"Diese Profile konnten nicht verbunden werden: {profiles}. Die übrigen Profile bleiben erhalten. {reason}",
|
||||
appProfilesDisconnectFailed:
|
||||
"Diese Profile konnten nicht getrennt werden und können weiterhin Erinnerungen nutzen: {profiles}. Bitte versuchen Sie es erneut. {reason}",
|
||||
appProfilesRevokedRetained:
|
||||
"Der Erinnerungszugriff dieser Profile wurde widerrufen, aber nicht alle Verbindungsdateien wurden entfernt; prüfen Sie sie: {profiles}.",
|
||||
appProfileNameUnsupported:
|
||||
"Profilnamen dürfen nur Kleinbuchstaben, Ziffern und Bindestriche enthalten, höchstens 32 Zeichen",
|
||||
"apps.autoHostProfilesFailed":
|
||||
"Diese Profile von {name} konnten nicht automatisch verbunden werden ({profiles}){reason}.",
|
||||
"Cursor 的连接凭证已更新。请完全退出并重新打开 Cursor,再开始新对话。":
|
||||
"Die Verbindungs-Anmeldedaten von Cursor haben sich geändert. Beenden Sie Cursor vollständig und öffnen Sie es erneut, bevor Sie eine neue Unterhaltung beginnen.",
|
||||
连接其他工具: "Andere Tools verbinden",
|
||||
|
||||
@@ -723,6 +723,8 @@ export const frenchMessages: Record<MessageKey, string> = {
|
||||
nativeOpen: "Ouvrir {product}",
|
||||
nativeQuit:
|
||||
"Quitter {product} (le service en arrière-plan continue de fonctionner)",
|
||||
nativeQuitStopsService:
|
||||
"Quitter {product} (le service de mémoire s’arrête aussi)",
|
||||
nativeLoginError:
|
||||
"Impossible de modifier le réglage de lancement à l’ouverture de session. Veuillez réessayer.",
|
||||
nativeMoveTitle: "Déplacez d’abord MemHub dans Applications",
|
||||
@@ -1116,6 +1118,23 @@ export const frenchMessages: Record<MessageKey, string> = {
|
||||
"L’accès aux mémoires a été révoqué : {name} ne peut plus rechercher de mémoires. La suppression complète n’a pas pu être confirmée ; vérifiez manuellement ces chemins : {paths}.",
|
||||
appRemovalReview:
|
||||
"La suppression complète de la connexion n’a pas pu être confirmée. Vérifiez manuellement ces chemins : {paths}.",
|
||||
appProfilesLabel: "Profils de {name}",
|
||||
appProfileOn: "{profile} : connecté",
|
||||
appProfileOff: "{profile} : non connecté",
|
||||
appProfilesHint:
|
||||
"L’interrupteur installe la connexion aux mémoires et accorde l’accès à chaque profil détecté, puis les révoque tous lorsqu’il est désactivé. Tant qu’il est activé, les nouveaux profils sont connectés automatiquement.",
|
||||
appProfilesNone:
|
||||
"Aucun profil de {name} n’a été détecté. Créez un profil, puis revenez sur cette page.",
|
||||
appProfilesConnectFailed:
|
||||
"Ces profils n’ont pas pu être connectés : {profiles}. Les autres profils sont conservés. {reason}",
|
||||
appProfilesDisconnectFailed:
|
||||
"Ces profils n’ont pas pu être déconnectés et peuvent encore utiliser les mémoires : {profiles}. Réessayez. {reason}",
|
||||
appProfilesRevokedRetained:
|
||||
"L’accès aux mémoires de ces profils a été révoqué, mais tous leurs fichiers de connexion n’ont pas été supprimés ; vérifiez-les : {profiles}.",
|
||||
appProfileNameUnsupported:
|
||||
"les noms de profil ne peuvent contenir que des minuscules, des chiffres et des tirets, 32 caractères au maximum",
|
||||
"apps.autoHostProfilesFailed":
|
||||
"Ces profils de {name} n’ont pas pu être connectés automatiquement ({profiles}){reason}.",
|
||||
"Cursor 的连接凭证已更新。请完全退出并重新打开 Cursor,再开始新对话。":
|
||||
"L’identifiant de connexion de Cursor a changé. Quittez complètement Cursor et rouvrez-le avant de démarrer une nouvelle conversation.",
|
||||
连接其他工具: "Connecter d’autres outils",
|
||||
|
||||
@@ -699,6 +699,8 @@ export const indonesianMessages: Record<MessageKey, string> = {
|
||||
nativeDevelopmentUnavailable: " (tidak tersedia saat pengembangan)",
|
||||
nativeOpen: "Buka {product}",
|
||||
nativeQuit: "Keluar dari {product} (layanan latar belakang tetap berjalan)",
|
||||
nativeQuitStopsService:
|
||||
"Keluar dari {product} (layanan memori juga dihentikan)",
|
||||
nativeLoginError:
|
||||
"Tidak dapat mengubah pengaturan mulai saat masuk. Silakan coba lagi.",
|
||||
nativeMoveTitle: "Pindahkan MemHub ke Aplikasi terlebih dahulu",
|
||||
@@ -1086,6 +1088,23 @@ export const indonesianMessages: Record<MessageKey, string> = {
|
||||
"Akses memori telah dicabut, sehingga {name} tidak dapat lagi mencari memori. Penghapusan penuh belum dapat dikonfirmasi; tinjau jalur berikut secara manual: {paths}.",
|
||||
appRemovalReview:
|
||||
"Penghapusan koneksi penuh belum dapat dikonfirmasi. Tinjau jalur berikut secara manual: {paths}.",
|
||||
appProfilesLabel: "Profil {name}",
|
||||
appProfileOn: "{profile}: terhubung",
|
||||
appProfileOff: "{profile}: belum terhubung",
|
||||
appProfilesHint:
|
||||
"Sakelar ini memasang koneksi memori dan memberi akses untuk setiap profil yang terdeteksi, lalu mencabut semuanya saat dimatikan. Selama sakelar menyala, profil baru akan terhubung otomatis.",
|
||||
appProfilesNone:
|
||||
"Tidak ada profil {name} yang terdeteksi. Buat profil, lalu kembali ke halaman ini.",
|
||||
appProfilesConnectFailed:
|
||||
"Profil berikut tidak dapat dihubungkan: {profiles}. Profil lainnya tetap dipertahankan. {reason}",
|
||||
appProfilesDisconnectFailed:
|
||||
"Profil berikut tidak dapat diputuskan dan masih dapat memakai memori: {profiles}. Coba lagi. {reason}",
|
||||
appProfilesRevokedRetained:
|
||||
"Akses memori untuk profil berikut telah dicabut, tetapi tidak semua file koneksinya dihapus; tinjau: {profiles}.",
|
||||
appProfileNameUnsupported:
|
||||
"nama profil hanya boleh berisi huruf kecil, angka, dan tanda hubung, maksimal 32 karakter",
|
||||
"apps.autoHostProfilesFailed":
|
||||
"Profil {name} berikut tidak dapat terhubung otomatis ({profiles}){reason}.",
|
||||
"Cursor 的连接凭证已更新。请完全退出并重新打开 Cursor,再开始新对话。":
|
||||
"Kredensial koneksi Cursor telah berubah. Keluar sepenuhnya lalu buka kembali Cursor sebelum memulai percakapan baru.",
|
||||
连接其他工具: "Hubungkan alat lain",
|
||||
|
||||
@@ -697,6 +697,7 @@ export const japaneseMessages: Record<MessageKey, string> = {
|
||||
nativeDevelopmentUnavailable: " (開発モードでは利用不可)",
|
||||
nativeOpen: "{product} を開く",
|
||||
nativeQuit: "{product} を終了(バックグラウンドサービスは引き続き実行)",
|
||||
nativeQuitStopsService: "{product} を終了(メモリサービスも停止)",
|
||||
nativeLoginError:
|
||||
"ログイン時の起動設定を変更できませんでした。もう一度お試しください。",
|
||||
nativeMoveTitle:
|
||||
@@ -1083,6 +1084,23 @@ export const japaneseMessages: Record<MessageKey, string> = {
|
||||
"記憶へのアクセスを取り消したため、{name} は記憶を照会できなくなりました。接続が完全に削除されたか確認できませんでした。次のパスを手動で確認してください:{paths}。",
|
||||
appRemovalReview:
|
||||
"接続が完全に削除されたか確認できませんでした。次のパスを手動で確認してください:{paths}。",
|
||||
appProfilesLabel: "{name} のプロファイル",
|
||||
appProfileOn: "{profile}:接続済み",
|
||||
appProfileOff: "{profile}:未接続",
|
||||
appProfilesHint:
|
||||
"このスイッチは検出されたすべてのプロファイルに記憶の接続をインストールしてアクセスを許可し、オフにするとすべて取り消します。オンの間は、新しいプロファイルも自動的に接続されます。",
|
||||
appProfilesNone:
|
||||
"{name} のプロファイルが検出されませんでした。プロファイルを作成してから、このページに戻ってください。",
|
||||
appProfilesConnectFailed:
|
||||
"次のプロファイルを接続できませんでした:{profiles}。ほかのプロファイルはそのまま維持されます。{reason}",
|
||||
appProfilesDisconnectFailed:
|
||||
"次のプロファイルの接続を解除できず、引き続き記憶を使用できます:{profiles}。もう一度お試しください。{reason}",
|
||||
appProfilesRevokedRetained:
|
||||
"次のプロファイルの記憶へのアクセスを取り消しましたが、接続ファイルの一部は削除されていません。確認してください:{profiles}。",
|
||||
appProfileNameUnsupported:
|
||||
"プロファイル名に使えるのは小文字、数字、ハイフンのみで、32 文字までです",
|
||||
"apps.autoHostProfilesFailed":
|
||||
"{name} の次のプロファイルを自動的に接続できませんでした({profiles}){reason}。",
|
||||
"Cursor 的连接凭证已更新。请完全退出并重新打开 Cursor,再开始新对话。":
|
||||
"Cursor の接続の認証情報が更新されました。Cursor を完全に終了して開き直してから、新しい会話を始めてください。",
|
||||
连接其他工具: "ほかのツールを接続",
|
||||
|
||||
@@ -678,6 +678,7 @@ export const koreanMessages: Record<MessageKey, string> = {
|
||||
nativeDevelopmentUnavailable: " (개발 모드에서 사용 불가)",
|
||||
nativeOpen: "{product} 열기",
|
||||
nativeQuit: "{product} 종료(백그라운드 서비스는 계속 실행)",
|
||||
nativeQuitStopsService: "{product} 종료(메모리 서비스도 중지)",
|
||||
nativeLoginError:
|
||||
"로그인 시 실행 설정을 변경하지 못했습니다. 다시 시도하세요.",
|
||||
nativeMoveTitle: "먼저 MemHub를 응용 프로그램 폴더로 옮기세요",
|
||||
@@ -1060,6 +1061,23 @@ export const koreanMessages: Record<MessageKey, string> = {
|
||||
"기억 접근 권한이 취소되어 {name}은(는) 더 이상 기억을 조회할 수 없습니다. 완전히 제거되었는지 확인할 수 없으니 다음 경로를 직접 확인하세요: {paths}.",
|
||||
appRemovalReview:
|
||||
"연결이 완전히 제거되었는지 확인할 수 없습니다. 다음 경로를 직접 확인하세요: {paths}.",
|
||||
appProfilesLabel: "{name} 프로필",
|
||||
appProfileOn: "{profile}: 연결됨",
|
||||
appProfileOff: "{profile}: 연결 안 됨",
|
||||
appProfilesHint:
|
||||
"이 스위치는 감지된 모든 프로필에 기억 연결을 설치하고 접근을 허용하며, 끄면 모두 취소합니다. 켜져 있는 동안 새 프로필은 자동으로 연결됩니다.",
|
||||
appProfilesNone:
|
||||
"{name} 프로필이 감지되지 않았습니다. 프로필을 만든 뒤 이 페이지로 돌아오세요.",
|
||||
appProfilesConnectFailed:
|
||||
"다음 프로필을 연결하지 못했습니다: {profiles}. 다른 프로필은 유지됩니다. {reason}",
|
||||
appProfilesDisconnectFailed:
|
||||
"다음 프로필의 연결을 해제하지 못해 여전히 기억을 사용할 수 있습니다: {profiles}. 다시 시도하세요. {reason}",
|
||||
appProfilesRevokedRetained:
|
||||
"다음 프로필의 기억 접근 권한을 취소했지만 연결 파일이 모두 제거되지는 않았습니다. 확인하세요: {profiles}.",
|
||||
appProfileNameUnsupported:
|
||||
"프로필 이름에는 소문자, 숫자, 하이픈만 사용할 수 있으며 최대 32자입니다",
|
||||
"apps.autoHostProfilesFailed":
|
||||
"{name}의 다음 프로필을 자동으로 연결하지 못했습니다({profiles}){reason}.",
|
||||
"Cursor 的连接凭证已更新。请完全退出并重新打开 Cursor,再开始新对话。":
|
||||
"Cursor의 연결 자격 증명이 변경되었습니다. 새 대화를 시작하기 전에 Cursor를 완전히 종료했다가 다시 여세요.",
|
||||
连接其他工具: "다른 도구 연결",
|
||||
|
||||
@@ -718,6 +718,8 @@ export const portugueseMessages: Record<MessageKey, string> = {
|
||||
nativeOpen: "Abrir {product}",
|
||||
nativeQuit:
|
||||
"Sair do {product} (o serviço em segundo plano continua em execução)",
|
||||
nativeQuitStopsService:
|
||||
"Sair do {product} (o serviço de memória também é encerrado)",
|
||||
nativeLoginError:
|
||||
"Não foi possível alterar a configuração de abertura ao iniciar sessão. Tente novamente.",
|
||||
nativeMoveTitle: "Mova o MemHub para Aplicativos primeiro",
|
||||
@@ -1107,6 +1109,23 @@ export const portugueseMessages: Record<MessageKey, string> = {
|
||||
"O acesso às memórias foi revogado, então {name} não pode mais consultar memórias. Não foi possível confirmar a remoção completa; revise estes caminhos manualmente: {paths}.",
|
||||
appRemovalReview:
|
||||
"Não foi possível confirmar a remoção completa da conexão. Revise estes caminhos manualmente: {paths}.",
|
||||
appProfilesLabel: "Perfis de {name}",
|
||||
appProfileOn: "{profile}: conectado",
|
||||
appProfileOff: "{profile}: não conectado",
|
||||
appProfilesHint:
|
||||
"O interruptor instala a conexão de memórias e concede acesso a cada perfil detectado, e revoga todos ao ser desligado. Enquanto estiver ligado, novos perfis são conectados automaticamente.",
|
||||
appProfilesNone:
|
||||
"Nenhum perfil de {name} foi detectado. Crie um perfil e volte a esta página.",
|
||||
appProfilesConnectFailed:
|
||||
"Não foi possível conectar estes perfis: {profiles}. Os outros perfis foram mantidos. {reason}",
|
||||
appProfilesDisconnectFailed:
|
||||
"Não foi possível desconectar estes perfis, que ainda podem usar memórias: {profiles}. Tente novamente. {reason}",
|
||||
appProfilesRevokedRetained:
|
||||
"O acesso às memórias foi revogado para estes perfis, mas nem todos os arquivos de conexão foram removidos; revise-os: {profiles}.",
|
||||
appProfileNameUnsupported:
|
||||
"nomes de perfil só podem usar letras minúsculas, dígitos e hifens, até 32 caracteres",
|
||||
"apps.autoHostProfilesFailed":
|
||||
"Não foi possível conectar automaticamente estes perfis de {name} ({profiles}){reason}.",
|
||||
"Cursor 的连接凭证已更新。请完全退出并重新打开 Cursor,再开始新对话。":
|
||||
"A credencial de conexão do Cursor mudou. Feche o Cursor por completo e abra novamente antes de iniciar uma nova conversa.",
|
||||
连接其他工具: "Conectar outras ferramentas",
|
||||
|
||||
@@ -705,6 +705,7 @@ export const russianMessages: Record<MessageKey, string> = {
|
||||
nativeDevelopmentUnavailable: " (недоступно в режиме разработки)",
|
||||
nativeOpen: "Открыть {product}",
|
||||
nativeQuit: "Выйти из {product} (фоновая служба продолжит работу)",
|
||||
nativeQuitStopsService: "Выйти из {product} (служба памяти тоже остановится)",
|
||||
nativeLoginError:
|
||||
"Не удалось изменить настройку запуска при входе. Повторите попытку.",
|
||||
nativeMoveTitle: "Сначала переместите MemHub в «Программы»",
|
||||
@@ -1092,6 +1093,23 @@ export const russianMessages: Record<MessageKey, string> = {
|
||||
"Доступ к памяти отозван: {name} больше не может искать записи памяти. Полное удаление не подтверждено; проверьте эти пути вручную: {paths}.",
|
||||
appRemovalReview:
|
||||
"Полное удаление подключения не подтверждено. Проверьте эти пути вручную: {paths}.",
|
||||
appProfilesLabel: "Профили {name}",
|
||||
appProfileOn: "{profile}: подключён",
|
||||
appProfileOff: "{profile}: не подключён",
|
||||
appProfilesHint:
|
||||
"Переключатель устанавливает подключение к памяти и выдаёт доступ каждому найденному профилю, а при выключении отзывает доступ у всех. Пока он включён, новые профили подключаются автоматически.",
|
||||
appProfilesNone:
|
||||
"Профили {name} не найдены. Создайте профиль и вернитесь на эту страницу.",
|
||||
appProfilesConnectFailed:
|
||||
"Не удалось подключить профили: {profiles}. Остальные профили сохранены. {reason}",
|
||||
appProfilesDisconnectFailed:
|
||||
"Не удалось отключить профили, они всё ещё могут использовать память: {profiles}. Повторите попытку. {reason}",
|
||||
appProfilesRevokedRetained:
|
||||
"Доступ к памяти для этих профилей отозван, но не все файлы подключения удалены; проверьте их: {profiles}.",
|
||||
appProfileNameUnsupported:
|
||||
"имя профиля может содержать только строчные буквы, цифры и дефисы, не более 32 символов",
|
||||
"apps.autoHostProfilesFailed":
|
||||
"Не удалось автоматически подключить эти профили {name} ({profiles}){reason}.",
|
||||
"Cursor 的连接凭证已更新。请完全退出并重新打开 Cursor,再开始新对话。":
|
||||
"Учётные данные подключения Cursor изменились. Полностью закройте и снова откройте Cursor, прежде чем начинать новый диалог.",
|
||||
连接其他工具: "Подключить другие инструменты",
|
||||
|
||||
@@ -625,6 +625,7 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
|
||||
nativeDevelopmentUnavailable: "(開發模式不可用)",
|
||||
nativeOpen: "開啟 {product}",
|
||||
nativeQuit: "結束 {product}(背景服務會繼續執行)",
|
||||
nativeQuitStopsService: "結束 {product}(同時停止記憶服務)",
|
||||
nativeLoginError: "無法更改登入啟動設定,請重試。",
|
||||
nativeMoveTitle: "請先將 MemHub 移到「應用程式」檔案夾",
|
||||
nativeMoveDetail:
|
||||
@@ -990,6 +991,22 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
|
||||
appRevokedReview:
|
||||
"已撤銷記憶權限,{name} 已無法查詢記憶。但無法確認連線已全部移除,請手動核對以下路徑:{paths}。",
|
||||
appRemovalReview: "無法確認連線已全部移除,請手動核對以下路徑:{paths}。",
|
||||
appProfilesLabel: "{name} 的 Profile",
|
||||
appProfileOn: "{profile}:已串接",
|
||||
appProfileOff: "{profile}:未串接",
|
||||
appProfilesHint:
|
||||
"開關會為每個已偵測到的 Profile 安裝記憶連線並授權,關閉時全部撤銷。開關開啟期間,新建立的 Profile 會自動串接。",
|
||||
appProfilesNone: "尚未偵測到 {name} 的 Profile。建立 Profile 後請回到此頁。",
|
||||
appProfilesConnectFailed:
|
||||
"以下 Profile 未能串接:{profiles}。其他 Profile 已保留。{reason}",
|
||||
appProfilesDisconnectFailed:
|
||||
"以下 Profile 的連線未能移除,記憶權限也未能撤銷,仍可使用記憶:{profiles}。請重試。{reason}",
|
||||
appProfilesRevokedRetained:
|
||||
"已撤銷以下 Profile 的記憶權限,但連線檔案沒有全部移除,請核對:{profiles}。",
|
||||
appProfileNameUnsupported:
|
||||
"Profile 名稱只能包含小寫字母、數字和連字號,最長 32 個字元",
|
||||
"apps.autoHostProfilesFailed":
|
||||
"{name} 的以下 Profile 未能自動串接({profiles}){reason}。",
|
||||
"Cursor 的连接凭证已更新。请完全退出并重新打开 Cursor,再开始新对话。":
|
||||
"Cursor 的連線憑證已更新。請完全結束並重新開啟 Cursor,再開始新對話。",
|
||||
连接其他工具: "連線其他工具",
|
||||
|
||||
@@ -21,6 +21,7 @@ interface Options {
|
||||
permissions: HostPermissions;
|
||||
layouts: ReadonlyMap<string, RecallHostLayout>;
|
||||
hosts?: readonly RecallHostDescriptor[];
|
||||
discoverHosts?: () => Promise<readonly RecallHostDescriptor[]>;
|
||||
}
|
||||
|
||||
/** Constructed inside the Runtime with its existing permission owner. */
|
||||
@@ -28,6 +29,7 @@ export function createConnectionControlService(options: Options) {
|
||||
const hostControl = createHostControlService({
|
||||
permissions: options.permissions,
|
||||
hosts: options.hosts ?? [],
|
||||
...(options.discoverHosts ? { discover: options.discoverHosts } : {}),
|
||||
});
|
||||
let controller: Promise<ConnectionController | null> | null = null;
|
||||
let pinned: string | null = null;
|
||||
|
||||
@@ -16,9 +16,18 @@ export interface RuntimeAttachment {
|
||||
* `degraded`, never `healthy`. Null/absent when the receipt predates the
|
||||
* field (an older Runtime), which is treated as "unknown", not "healthy". */
|
||||
sidecarState?: "running" | "failed" | "stopped" | null;
|
||||
/** Build the Runtime published in its receipt. `undefined` when the receipt
|
||||
* predates the field (a Runtime older than every build that publishes it);
|
||||
* `null` members when the Runtime does not know its own build. */
|
||||
runtimeBuild?: RuntimeReceiptBuild;
|
||||
health: RuntimeIdentity;
|
||||
}
|
||||
|
||||
export interface RuntimeReceiptBuild {
|
||||
buildNumber: number | null;
|
||||
commit: string | null;
|
||||
}
|
||||
|
||||
export interface RuntimeAttachmentOptions {
|
||||
dataDir: string;
|
||||
processIsAlive(pid: number): boolean;
|
||||
@@ -63,6 +72,7 @@ interface RuntimeServiceReceipt {
|
||||
status: "starting" | "running" | "stopping";
|
||||
corePort: number | null;
|
||||
sidecarState: "running" | "failed" | "stopped" | null;
|
||||
build?: RuntimeReceiptBuild;
|
||||
}
|
||||
|
||||
/** The Core port a *running* managed Runtime published, or null when there is
|
||||
@@ -143,6 +153,9 @@ export async function discoverRuntimeAttachment(
|
||||
port: receipt.port,
|
||||
servicePid: service?.pid ?? null,
|
||||
sidecarState: service?.sidecarState ?? null,
|
||||
...(service?.build === undefined
|
||||
? {}
|
||||
: { runtimeBuild: Object.freeze({ ...service.build }) }),
|
||||
health: Object.freeze({ ...health }),
|
||||
});
|
||||
}
|
||||
@@ -190,6 +203,7 @@ async function readRuntimeServiceReceipt(
|
||||
) {
|
||||
throw new RuntimeDiscoveryError("runtime_service_receipt_invalid");
|
||||
}
|
||||
const build = receiptBuild(item);
|
||||
return {
|
||||
instanceId: item.instance_id,
|
||||
pid: item.pid,
|
||||
@@ -199,6 +213,28 @@ async function readRuntimeServiceReceipt(
|
||||
item.sidecar === undefined
|
||||
? null
|
||||
: (item.sidecar as RuntimeServiceReceipt["sidecarState"]),
|
||||
...(build === undefined ? {} : { build }),
|
||||
};
|
||||
}
|
||||
|
||||
/** A malformed build field reads as "unknown", never as a receipt error: the
|
||||
* build only decides whether an App adopts this Runtime, and an unknown build
|
||||
* makes it leave the Runtime alone. */
|
||||
function receiptBuild(
|
||||
item: Record<string, unknown>,
|
||||
): RuntimeReceiptBuild | undefined {
|
||||
if (!("build_number" in item) && !("git_commit" in item)) return undefined;
|
||||
const build = item.build_number;
|
||||
const commit = item.git_commit;
|
||||
return {
|
||||
buildNumber:
|
||||
typeof build === "number" && Number.isSafeInteger(build) && build > 0
|
||||
? build
|
||||
: null,
|
||||
commit:
|
||||
typeof commit === "string" && /^[0-9a-f]{7,40}$/.test(commit)
|
||||
? commit
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -194,6 +194,39 @@ it.each([
|
||||
expect(f.effects).toEqual([]);
|
||||
},
|
||||
);
|
||||
it("finds a Harness profile created after the Runtime started, and only a profile", async () => {
|
||||
const f = await fixture();
|
||||
const hostId = "deepseek-harness:later";
|
||||
const later = {
|
||||
hostId,
|
||||
label: "DeepSeek Harness · later",
|
||||
layout: { ...f.layout, hostId, hostLabel: "DeepSeek Harness · later" },
|
||||
presencePath: f.root,
|
||||
};
|
||||
const discover = vi.fn(async () => [...f.hosts, later]);
|
||||
const service = createHostControlService({
|
||||
permissions: f.permissions as unknown as HostPermissions,
|
||||
hosts: f.hosts,
|
||||
discover,
|
||||
});
|
||||
await service.handle({ hostId, operation: "getPermission", args: [] });
|
||||
expect(f.methods.getPermission).toHaveBeenCalledWith(later.layout);
|
||||
// Remembered: the disk is read once per new profile, not once per call.
|
||||
await service.handle({ hostId, operation: "getPermission", args: [] });
|
||||
expect(discover).toHaveBeenCalledTimes(1);
|
||||
await expect(
|
||||
service.handle({
|
||||
hostId: "deepseek-harness:absent",
|
||||
operation: "getPermission",
|
||||
args: [],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
expect(discover).toHaveBeenCalledTimes(2);
|
||||
await expect(
|
||||
service.handle({ hostId: "unknown", operation: "getPermission", args: [] }),
|
||||
).rejects.toThrow();
|
||||
expect(discover).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("clones nested arguments and trusted layout before an asynchronous owner observes them", async () => {
|
||||
const f = await fixture();
|
||||
let release!: () => void;
|
||||
|
||||
@@ -149,6 +149,8 @@ function validateArgs(operation: HostControlOperation, args: unknown[]): void {
|
||||
export function createHostControlService(options: {
|
||||
permissions: HostPermissions;
|
||||
hosts: readonly RecallHostDescriptor[];
|
||||
/** Re-reads the disk for a profile host created since this service started. */
|
||||
discover?: () => Promise<readonly RecallHostDescriptor[]>;
|
||||
}) {
|
||||
const permissions = options.permissions;
|
||||
const hosts = new Map<string, RecallHostDescriptor>();
|
||||
@@ -158,6 +160,17 @@ export function createHostControlService(options: {
|
||||
fail();
|
||||
hosts.set(source.hostId, structuredClone(source));
|
||||
}
|
||||
/** Only a profile id can appear after start; every other host is a fixed
|
||||
* path, so an unknown one is refused without touching the disk. */
|
||||
async function discovered(hostId: string) {
|
||||
if (!options.discover || !hostId.includes(":")) return undefined;
|
||||
const found = (await options.discover()).find(
|
||||
(source) =>
|
||||
source.hostId === hostId && source.layout.hostId === source.hostId,
|
||||
);
|
||||
if (found) hosts.set(hostId, structuredClone(found));
|
||||
return found;
|
||||
}
|
||||
return {
|
||||
async handle(input: unknown): Promise<unknown> {
|
||||
const request = exact(input, ["hostId", "operation", "args"]);
|
||||
@@ -169,7 +182,10 @@ export function createHostControlService(options: {
|
||||
!Array.isArray(request.args)
|
||||
)
|
||||
fail();
|
||||
const host = hosts.get(request.hostId);
|
||||
// A known host must not yield here: its arguments are cloned below,
|
||||
// before the caller's next tick can change them.
|
||||
const host =
|
||||
hosts.get(request.hostId) ?? (await discovered(request.hostId));
|
||||
if (!host) fail();
|
||||
const operation = request.operation as HostControlOperation;
|
||||
if (
|
||||
|
||||
@@ -71,6 +71,7 @@ export {
|
||||
type RuntimeAttachmentOptions,
|
||||
RuntimeDiscoveryError,
|
||||
type RuntimeIdentity,
|
||||
type RuntimeReceiptBuild,
|
||||
readPublishedCorePort,
|
||||
} from "./discovery.js";
|
||||
export {
|
||||
@@ -144,12 +145,17 @@ export {
|
||||
type RuntimeCliConfig,
|
||||
resolveRuntimeCliConfig,
|
||||
runRuntimeCli,
|
||||
runtimeBuildIdentity,
|
||||
} from "./runtime-cli.js";
|
||||
export {
|
||||
type EnsureRuntimeOptions,
|
||||
ensureRuntimeProcess,
|
||||
type RuntimeLaunch,
|
||||
readRuntimeLeaseOwnerPid,
|
||||
type StopAllRuntimesOptions,
|
||||
type StopAllRuntimesResult,
|
||||
type StopRuntimeOptions,
|
||||
stopAllRuntimeProcesses,
|
||||
stopRuntimeProcess,
|
||||
type WaitForRuntimeOptions,
|
||||
waitForRuntimeProcess,
|
||||
@@ -160,6 +166,7 @@ export {
|
||||
} from "./runtime-maintenance.js";
|
||||
export {
|
||||
type ManagedRuntime,
|
||||
type RuntimeBuildIdentity,
|
||||
RuntimeService,
|
||||
type RuntimeServiceOptions,
|
||||
} from "./runtime-service.js";
|
||||
@@ -235,6 +242,15 @@ export {
|
||||
runtimeInstallLayout,
|
||||
stableRuntimeInstallRoot,
|
||||
} from "./service-plan.js";
|
||||
export {
|
||||
parseLaunchctlPid,
|
||||
queryRuntimeServiceState,
|
||||
type RuntimeServiceState,
|
||||
type RuntimeServiceStateName,
|
||||
type RuntimeServiceStateOptions,
|
||||
type RuntimeServiceStateReason,
|
||||
type ServiceStateProbe,
|
||||
} from "./service-state.js";
|
||||
export { createSourceChangeHandler } from "./source-change-handler.js";
|
||||
export {
|
||||
type ApplySyncHookInput,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
resolveRuntimeCliConfig,
|
||||
revalidateHostRuntime,
|
||||
runRuntimeCli,
|
||||
runtimeBuildIdentity,
|
||||
waitForAppRemoval,
|
||||
} from "./runtime-cli.js";
|
||||
import {
|
||||
@@ -1030,3 +1031,18 @@ describe("Runtime CLI service plan", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the build a Runtime publishes in its receipt", () => {
|
||||
it("is its build number and commit, and null where it does not know", () => {
|
||||
expect(
|
||||
runtimeBuildIdentity({ buildNumber: "38", commit: "ABCDEF1234" }),
|
||||
).toEqual({ buildNumber: 38, commit: "abcdef1234" });
|
||||
expect(runtimeBuildIdentity(undefined)).toEqual({
|
||||
buildNumber: null,
|
||||
commit: null,
|
||||
});
|
||||
expect(
|
||||
runtimeBuildIdentity({ buildNumber: "dev", commit: "unknown" }),
|
||||
).toEqual({ buildNumber: null, commit: null });
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,10 @@ import { HostPermissions } from "./host-permissions.js";
|
||||
import { verifyInstalledDistribution } from "./local-install.js";
|
||||
import { RecallBrokerSupervisor } from "./recall-broker-supervisor.js";
|
||||
import { recallHosts } from "./recall-hosts.js";
|
||||
import { RuntimeService } from "./runtime-service.js";
|
||||
import {
|
||||
type RuntimeBuildIdentity,
|
||||
RuntimeService,
|
||||
} from "./runtime-service.js";
|
||||
import { RuntimeSupervisor } from "./runtime-supervisor.js";
|
||||
import {
|
||||
runSdkMigration,
|
||||
@@ -653,6 +656,19 @@ function boundFingerprint(
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/** The build this Runtime publishes in its receipt. Unknown stays `null`, never
|
||||
* a guess: an App treats an unknown build as "do not touch". */
|
||||
export function runtimeBuildIdentity(
|
||||
identity: RuntimeCliConfig["buildIdentity"],
|
||||
): RuntimeBuildIdentity {
|
||||
const build = Number(identity?.buildNumber);
|
||||
const commit = identity?.commit?.trim().toLowerCase() ?? "";
|
||||
return {
|
||||
buildNumber: Number.isSafeInteger(build) && build > 0 ? build : null,
|
||||
commit: /^[0-9a-f]{7,40}$/.test(commit) ? commit : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createRuntimeService(
|
||||
config: RuntimeCliConfig,
|
||||
): Promise<RuntimeService> {
|
||||
@@ -668,6 +684,7 @@ export async function createRuntimeService(
|
||||
return new RuntimeService({
|
||||
dataDir: config.dataDir,
|
||||
diagnostics,
|
||||
buildIdentity: runtimeBuildIdentity(config.buildIdentity),
|
||||
async createSupervisor(onSidecarFault) {
|
||||
const { port, overrideApplied, ignoredReason } = resolveCorePort(
|
||||
process.env,
|
||||
@@ -732,12 +749,14 @@ export async function createRuntimeService(
|
||||
verifyHostRuntime,
|
||||
config.hostRuntime?.manifestSha256 ?? "daily",
|
||||
);
|
||||
const hosts = (
|
||||
await recallHosts(config.hostHome ?? os.homedir(), {
|
||||
...process.env,
|
||||
ONE_MEMORY_HOST_HOME: config.hostHome,
|
||||
})
|
||||
).filter((host) => host.hostId !== "openclaw");
|
||||
const scanHosts = async () =>
|
||||
(
|
||||
await recallHosts(config.hostHome ?? os.homedir(), {
|
||||
...process.env,
|
||||
ONE_MEMORY_HOST_HOME: config.hostHome,
|
||||
})
|
||||
).filter((host) => host.hostId !== "openclaw");
|
||||
const hosts = await scanHosts();
|
||||
await verifyHostRuntime().then(
|
||||
async () => {
|
||||
// Every Runtime start after an upgrade, the App's or `memhub
|
||||
@@ -767,6 +786,9 @@ export async function createRuntimeService(
|
||||
connection,
|
||||
permissions,
|
||||
hosts,
|
||||
// The Runtime outlives the App: a Harness profile created after it
|
||||
// started must not make every later host read fail.
|
||||
discoverHosts: scanHosts,
|
||||
layouts: new Map(
|
||||
hosts.flatMap((host) =>
|
||||
host.hostId === "claude-code"
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { ChildProcess, SpawnOptions } from "node:child_process";
|
||||
import { spawn } from "node:child_process";
|
||||
import { access } from "node:fs/promises";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { HealthResponse } from "@one-memory/contracts";
|
||||
import axios from "axios";
|
||||
import { processIsAlive } from "./core-supervisor.js";
|
||||
import {
|
||||
discoverRuntimeAttachment,
|
||||
type RuntimeAttachment,
|
||||
RuntimeDiscoveryError,
|
||||
} from "./discovery.js";
|
||||
import { readSdkMigrationBlock } from "./sdk-migration-log.js";
|
||||
@@ -109,7 +110,7 @@ export async function ensureRuntimeProcess(
|
||||
|
||||
export async function waitForRuntimeProcess(
|
||||
options: WaitForRuntimeOptions,
|
||||
): Promise<void> {
|
||||
): Promise<RuntimeAttachment> {
|
||||
const attempts = options.attempts ?? 120;
|
||||
const delayMs = options.delayMs ?? 250;
|
||||
const waitStarted = Date.now();
|
||||
@@ -121,7 +122,7 @@ export async function waitForRuntimeProcess(
|
||||
probeIdentity: options.probeIdentity ?? defaultProbeIdentity,
|
||||
requireServiceReceipt: true,
|
||||
});
|
||||
if (attachment !== null) return;
|
||||
if (attachment !== null) return attachment;
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof RuntimeDiscoveryError) ||
|
||||
@@ -161,8 +162,117 @@ export async function stopRuntimeProcess(
|
||||
if (attachment.servicePid === null) {
|
||||
throw new RuntimeDiscoveryError("runtime_service_receipt_invalid");
|
||||
}
|
||||
await terminateVerifiedRuntime(options, attachment.servicePid);
|
||||
return "stopped";
|
||||
}
|
||||
|
||||
export interface StopAllRuntimesOptions extends StopRuntimeOptions {
|
||||
/** How long a Runtime caught starting or stopping may take to become
|
||||
* verifiable before the stop gives up on it. */
|
||||
settleMs?: number;
|
||||
/** Upper bound on Runtimes stopped in one call. The lease admits one at a
|
||||
* time, so more than a few means something keeps starting new ones. */
|
||||
maxRuntimes?: number;
|
||||
}
|
||||
|
||||
export interface StopAllRuntimesResult {
|
||||
/** Verified Runtime processes this call stopped. */
|
||||
stopped: number[];
|
||||
/** A live pid the Runtime lease names that could not be verified as a
|
||||
* MemHub Runtime of this data dir. Never signalled: a recycled pid belongs
|
||||
* to someone else. */
|
||||
unverified: number[];
|
||||
}
|
||||
|
||||
/** Stop every Runtime serving `dataDir`, whoever started it.
|
||||
*
|
||||
* The Runtime lease (`runtime-service.lock`) admits one Runtime per data dir,
|
||||
* so "every" is the current leaseholder plus any successor that takes the
|
||||
* lease while this runs: the App's own transient child, a `memhub start`, a
|
||||
* leftover from an earlier session. Each is found through its receipt and
|
||||
* signalled only once the authenticated identity endpoint has proved that the
|
||||
* receipt's pid is the Runtime serving this data dir. A live lease owner that
|
||||
* cannot be proved that way is reported, never signalled.
|
||||
*/
|
||||
export async function stopAllRuntimeProcesses(
|
||||
options: StopAllRuntimesOptions,
|
||||
): Promise<StopAllRuntimesResult> {
|
||||
const alive = options.processIsAlive ?? processIsAlive;
|
||||
const stopped: number[] = [];
|
||||
const settleDeadline = Date.now() + (options.settleMs ?? 2_000);
|
||||
const maxRuntimes = options.maxRuntimes ?? 3;
|
||||
while (stopped.length < maxRuntimes) {
|
||||
let attachment: RuntimeAttachment | null;
|
||||
try {
|
||||
attachment = await discoverRuntimeAttachment({
|
||||
dataDir: options.dataDir,
|
||||
processIsAlive: alive,
|
||||
probeIdentity: options.probeIdentity ?? defaultProbeIdentity,
|
||||
requireServiceReceipt: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (settling(error) && Date.now() < settleDeadline) {
|
||||
await delay(100);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (attachment === null) break;
|
||||
if (attachment.servicePid === null) {
|
||||
throw new RuntimeDiscoveryError("runtime_service_receipt_invalid");
|
||||
}
|
||||
await terminateVerifiedRuntime(options, attachment.servicePid);
|
||||
stopped.push(attachment.servicePid);
|
||||
}
|
||||
const leasePid = await readRuntimeLeaseOwnerPid(options.dataDir);
|
||||
const unverified =
|
||||
leasePid !== null &&
|
||||
leasePid !== process.pid &&
|
||||
!stopped.includes(leasePid) &&
|
||||
alive(leasePid)
|
||||
? [leasePid]
|
||||
: [];
|
||||
return { stopped, unverified };
|
||||
}
|
||||
|
||||
/** The pid the Runtime lease names, or null without a readable lease. */
|
||||
export async function readRuntimeLeaseOwnerPid(
|
||||
dataDir: string,
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
const owner = JSON.parse(
|
||||
await readFile(
|
||||
path.join(dataDir, "runtime-service.lock", "owner.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as { pid?: unknown };
|
||||
return typeof owner.pid === "number" &&
|
||||
Number.isInteger(owner.pid) &&
|
||||
owner.pid > 1
|
||||
? owner.pid
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A Runtime between states: starting, stopping, or swapping receipts. */
|
||||
function settling(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof RuntimeDiscoveryError &&
|
||||
(error.code === "runtime_service_not_ready" ||
|
||||
error.code === "runtime_service_receipt_invalid" ||
|
||||
error.code === "runtime_identity_unavailable")
|
||||
);
|
||||
}
|
||||
|
||||
async function terminateVerifiedRuntime(
|
||||
options: StopRuntimeOptions,
|
||||
pid: number,
|
||||
): Promise<void> {
|
||||
const alive = options.processIsAlive ?? processIsAlive;
|
||||
const signal = options.signalProcess ?? process.kill;
|
||||
signal(attachment.servicePid, "SIGTERM");
|
||||
signal(pid, "SIGTERM");
|
||||
const timeoutMs = options.timeoutMs ?? 15_000;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
// The process exiting is the authoritative stop signal. The receipt files
|
||||
@@ -171,10 +281,10 @@ export async function stopRuntimeProcess(
|
||||
const receiptGraceMs = Math.min(RECEIPT_CLEANUP_GRACE_MS, timeoutMs);
|
||||
let exitedAt: number | null = null;
|
||||
while (Date.now() < deadline) {
|
||||
if (!alive(attachment.servicePid)) {
|
||||
if (!alive(pid)) {
|
||||
exitedAt ??= Date.now();
|
||||
if (await receiptsAreGone(options.dataDir)) return "stopped";
|
||||
if (Date.now() - exitedAt >= receiptGraceMs) return "stopped";
|
||||
if (await receiptsAreGone(options.dataDir)) return;
|
||||
if (Date.now() - exitedAt >= receiptGraceMs) return;
|
||||
} else {
|
||||
exitedAt = null;
|
||||
}
|
||||
|
||||
@@ -73,6 +73,23 @@ async function lockEntries(dataDir: string): Promise<string[]> {
|
||||
}
|
||||
|
||||
describe("RuntimeService", () => {
|
||||
it("publishes its build so an App can compare it before adopting", async () => {
|
||||
const dataDir = await directory("runtime-service-build-");
|
||||
const service = new RuntimeService({
|
||||
dataDir,
|
||||
createSupervisor: () => supervisor(),
|
||||
pid: 4321,
|
||||
buildIdentity: { buildNumber: 38, commit: null },
|
||||
});
|
||||
|
||||
await service.start();
|
||||
const receipt = JSON.parse(
|
||||
await readFile(path.join(dataDir, "runtime-service.json"), "utf8"),
|
||||
);
|
||||
expect(receipt).toMatchObject({ build_number: 38, git_commit: null });
|
||||
await service.stop();
|
||||
});
|
||||
|
||||
it("publishes and removes an outer Runtime receipt without secrets", async () => {
|
||||
const dataDir = await directory("runtime-service-");
|
||||
const runtime = supervisor();
|
||||
|
||||
@@ -46,6 +46,16 @@ export interface RuntimeServiceOptions {
|
||||
* owner.json; see removeUnownedLease. Injectable for tests. */
|
||||
legacyBootTime?: () => number | null;
|
||||
diagnostics?: DiagnosticSink;
|
||||
/** Build this process runs, published in the receipt so an App can tell an
|
||||
* older or newer Runtime from its own before adopting it. `null` fields mean
|
||||
* "this Runtime does not know"; a receipt without the fields at all was
|
||||
* written by a Runtime older than this contract. */
|
||||
buildIdentity?: RuntimeBuildIdentity;
|
||||
}
|
||||
|
||||
export interface RuntimeBuildIdentity {
|
||||
buildNumber: number | null;
|
||||
commit: string | null;
|
||||
}
|
||||
|
||||
interface LeaseIdentity {
|
||||
@@ -406,6 +416,12 @@ export class RuntimeService {
|
||||
pid: this.pid(),
|
||||
core_port: Number(url.port),
|
||||
...(sidecar === undefined ? {} : { sidecar }),
|
||||
...(this.options.buildIdentity === undefined
|
||||
? {}
|
||||
: {
|
||||
build_number: this.options.buildIdentity.buildNumber,
|
||||
git_commit: this.options.buildIdentity.commit,
|
||||
}),
|
||||
started_at: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
stopAllRuntimeProcesses,
|
||||
waitForRuntimeProcess,
|
||||
} from "./runtime-launcher.js";
|
||||
|
||||
async function writeRuntime(
|
||||
dataDir: string,
|
||||
runtime: {
|
||||
instance: string;
|
||||
servicePid: number;
|
||||
corePid: number;
|
||||
build?: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(dataDir, "runtime.json"),
|
||||
JSON.stringify({
|
||||
schema_version: 1,
|
||||
service: "one-memory-core",
|
||||
instance_id: runtime.instance,
|
||||
pid: runtime.corePid,
|
||||
parent_pid: runtime.servicePid,
|
||||
host: "127.0.0.1",
|
||||
port: 8765,
|
||||
started_at: "now",
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(dataDir, "runtime-token"), "token", {
|
||||
mode: 0o600,
|
||||
});
|
||||
await writeFile(
|
||||
path.join(dataDir, "runtime-service.json"),
|
||||
JSON.stringify({
|
||||
schema_version: 1,
|
||||
service: "one-memory-runtime",
|
||||
status: "running",
|
||||
instance_id: runtime.instance,
|
||||
pid: runtime.servicePid,
|
||||
core_port: 8765,
|
||||
...runtime.build,
|
||||
started_at: "now",
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
|
||||
async function writeLease(dataDir: string, pid: number) {
|
||||
await mkdir(path.join(dataDir, "runtime-service.lock"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(dataDir, "runtime-service.lock", "owner.json"),
|
||||
JSON.stringify({ pid, created_at: "now", boot_id: null }),
|
||||
);
|
||||
}
|
||||
|
||||
async function removeRuntime(dataDir: string) {
|
||||
for (const name of ["runtime.json", "runtime-service.json", "runtime-token"])
|
||||
await rm(path.join(dataDir, name), { force: true });
|
||||
await rm(path.join(dataDir, "runtime-service.lock"), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
const identityFor = (live: Map<number, string>) =>
|
||||
async function probeIdentity() {
|
||||
// The identity endpoint answers for whichever Runtime holds the receipts.
|
||||
const instance = [...live.values()].at(-1) ?? "none";
|
||||
return {
|
||||
status: "ok" as const,
|
||||
service: "one-memory-core" as const,
|
||||
version: "1.0.0",
|
||||
instance_id: instance,
|
||||
};
|
||||
};
|
||||
|
||||
describe("stopping every Runtime of a data dir", () => {
|
||||
it("stops the App's own child and an externally started successor", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-stop-all-"));
|
||||
// pid -> instance; core pids are always alive while their service is.
|
||||
const live = new Map<number, string>([[4321, "app-child"]]);
|
||||
await writeRuntime(dataDir, {
|
||||
instance: "app-child",
|
||||
servicePid: 4321,
|
||||
corePid: 1234,
|
||||
});
|
||||
await writeLease(dataDir, 4321);
|
||||
const signalProcess = vi.fn(async (pid: number) => {
|
||||
live.delete(pid);
|
||||
await removeRuntime(dataDir);
|
||||
if (pid === 4321) {
|
||||
// `memhub start` was waiting on the lease and takes over at once.
|
||||
live.set(5555, "cli-started");
|
||||
await writeRuntime(dataDir, {
|
||||
instance: "cli-started",
|
||||
servicePid: 5555,
|
||||
corePid: 5556,
|
||||
});
|
||||
await writeLease(dataDir, 5555);
|
||||
}
|
||||
});
|
||||
|
||||
const result = await stopAllRuntimeProcesses({
|
||||
dataDir,
|
||||
timeoutMs: 2_000,
|
||||
processIsAlive: (pid) =>
|
||||
live.has(pid) ||
|
||||
(pid === 1234 && live.has(4321)) ||
|
||||
(pid === 5556 && live.has(5555)),
|
||||
signalProcess: (pid) => void signalProcess(pid),
|
||||
probeIdentity: identityFor(live),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ stopped: [4321, 5555], unverified: [] });
|
||||
expect(signalProcess.mock.calls.map(([pid]) => pid)).toEqual([4321, 5555]);
|
||||
});
|
||||
|
||||
it("never signals a live lease owner it cannot verify as a MemHub Runtime", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-stop-all-"));
|
||||
// A lease left by a crashed Runtime whose pid the OS gave to another
|
||||
// program: no receipt, no identity endpoint, just a live number.
|
||||
await writeLease(dataDir, 7777);
|
||||
const signalProcess = vi.fn();
|
||||
|
||||
const result = await stopAllRuntimeProcesses({
|
||||
dataDir,
|
||||
processIsAlive: (pid) => pid === 7777,
|
||||
signalProcess,
|
||||
probeIdentity: async () => {
|
||||
throw new Error("no endpoint");
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ stopped: [], unverified: [7777] });
|
||||
expect(signalProcess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to signal a receipt pid whose identity does not match", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-stop-all-"));
|
||||
await writeRuntime(dataDir, {
|
||||
instance: "runtime",
|
||||
servicePid: 4321,
|
||||
corePid: 1234,
|
||||
});
|
||||
const signalProcess = vi.fn();
|
||||
|
||||
await expect(
|
||||
stopAllRuntimeProcesses({
|
||||
dataDir,
|
||||
processIsAlive: () => true,
|
||||
signalProcess,
|
||||
probeIdentity: async () => ({
|
||||
status: "ok",
|
||||
service: "one-memory-core",
|
||||
version: "1.0.0",
|
||||
instance_id: "someone-else",
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow("runtime_identity_mismatch");
|
||||
expect(signalProcess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports nothing to stop for an empty data dir", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-stop-all-"));
|
||||
await expect(
|
||||
stopAllRuntimeProcesses({
|
||||
dataDir,
|
||||
processIsAlive: () => false,
|
||||
signalProcess: vi.fn(),
|
||||
}),
|
||||
).resolves.toEqual({ stopped: [], unverified: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the build a running Runtime publishes", () => {
|
||||
const probeIdentity = async () => ({
|
||||
status: "ok" as const,
|
||||
service: "one-memory-core" as const,
|
||||
version: "1.0.0",
|
||||
instance_id: "runtime",
|
||||
});
|
||||
|
||||
it("is read from its receipt", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-build-"));
|
||||
await writeRuntime(dataDir, {
|
||||
instance: "runtime",
|
||||
servicePid: 4321,
|
||||
corePid: 1234,
|
||||
build: { build_number: 38, git_commit: "abcdef1" },
|
||||
});
|
||||
|
||||
const attachment = await waitForRuntimeProcess({
|
||||
dataDir,
|
||||
attempts: 1,
|
||||
processIsAlive: () => true,
|
||||
probeIdentity,
|
||||
});
|
||||
|
||||
expect(attachment.servicePid).toBe(4321);
|
||||
expect(attachment.runtimeBuild).toEqual({
|
||||
buildNumber: 38,
|
||||
commit: "abcdef1",
|
||||
});
|
||||
});
|
||||
|
||||
it("is absent for a receipt from a Runtime older than the field", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-build-"));
|
||||
await writeRuntime(dataDir, {
|
||||
instance: "runtime",
|
||||
servicePid: 4321,
|
||||
corePid: 1234,
|
||||
});
|
||||
|
||||
const attachment = await waitForRuntimeProcess({
|
||||
dataDir,
|
||||
attempts: 1,
|
||||
processIsAlive: () => true,
|
||||
probeIdentity,
|
||||
});
|
||||
|
||||
expect(attachment.runtimeBuild).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads a malformed build as unknown, not as a broken receipt", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "runtime-build-"));
|
||||
await writeRuntime(dataDir, {
|
||||
instance: "runtime",
|
||||
servicePid: 4321,
|
||||
corePid: 1234,
|
||||
build: { build_number: "38", git_commit: "not a commit" },
|
||||
});
|
||||
|
||||
const attachment = await waitForRuntimeProcess({
|
||||
dataDir,
|
||||
attempts: 1,
|
||||
processIsAlive: () => true,
|
||||
probeIdentity,
|
||||
});
|
||||
|
||||
expect(attachment.runtimeBuild).toEqual({
|
||||
buildNumber: null,
|
||||
commit: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
parseLaunchctlPid,
|
||||
queryRuntimeServiceState,
|
||||
type ServiceStateProbe,
|
||||
} from "./service-state.js";
|
||||
|
||||
const LABEL = "com.tencent.onememory.runtime";
|
||||
|
||||
async function home() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "service-state-"));
|
||||
const dataDir = path.join(root, ".one-memory");
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
return { root, dataDir };
|
||||
}
|
||||
|
||||
async function writeLaunchAgent(root: string, dataDir: string, label = LABEL) {
|
||||
const directory = path.join(root, "Library", "LaunchAgents");
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(directory, `${label}.plist`),
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0"><dict>
|
||||
<key>Label</key><string>${label}</string>
|
||||
<key>EnvironmentVariables</key><dict><key>ONE_MEMORY_DATA_DIR</key><string>${dataDir.replaceAll("&", "&")}</string></dict>
|
||||
</dict></plist>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
function launchctl(options: {
|
||||
disabled?: boolean;
|
||||
job?: { code: number; pid?: number };
|
||||
overridesCode?: number;
|
||||
}): ServiceStateProbe {
|
||||
return vi.fn(async (command: readonly string[]) => {
|
||||
if (command[1] === "print-disabled") {
|
||||
return {
|
||||
code: options.overridesCode ?? 0,
|
||||
stdout: `disabled services = {\n\t"${LABEL}" => ${options.disabled ? "disabled" : "enabled"}\n}\n`,
|
||||
};
|
||||
}
|
||||
const job = options.job ?? { code: 0, pid: 4321 };
|
||||
return {
|
||||
code: job.code,
|
||||
stdout:
|
||||
job.code === 0
|
||||
? `${LABEL} = {\n\tpath = /x.plist\n${job.pid ? `\tpid = ${job.pid}\n` : ""}\tendpoints = {\n\t\tpid = 1\n\t}\n}\n`
|
||||
: "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
describe("asking launchd whether the background service is on", () => {
|
||||
it("is enabled for an installed, loaded job with its enable marker", async () => {
|
||||
const { root, dataDir } = await home();
|
||||
await writeLaunchAgent(root, dataDir);
|
||||
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "darwin",
|
||||
uid: 501,
|
||||
probe: launchctl({}),
|
||||
markerEnabled: async () => true,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
state: "enabled",
|
||||
reason: "service-enabled",
|
||||
jobPid: 4321,
|
||||
});
|
||||
});
|
||||
|
||||
it("is disabled when no LaunchAgent serves this data dir", async () => {
|
||||
const { root, dataDir } = await home();
|
||||
const probe = launchctl({});
|
||||
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "darwin",
|
||||
uid: 501,
|
||||
probe,
|
||||
markerEnabled: async () => true,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
state: "disabled",
|
||||
reason: "service-not-installed",
|
||||
});
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a LaunchAgent that serves another data dir", async () => {
|
||||
const { root, dataDir } = await home();
|
||||
await writeLaunchAgent(root, path.join(root, "other-data"));
|
||||
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "darwin",
|
||||
uid: 501,
|
||||
probe: launchctl({}),
|
||||
markerEnabled: async () => true,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
state: "disabled",
|
||||
reason: "service-not-installed",
|
||||
});
|
||||
});
|
||||
|
||||
it("finds an isolated install root's scoped label", async () => {
|
||||
const { root, dataDir } = await home();
|
||||
await writeLaunchAgent(root, dataDir, `${LABEL}.0123456789abcdef`);
|
||||
const probe: ServiceStateProbe = async (command) =>
|
||||
command[1] === "print-disabled"
|
||||
? { code: 0, stdout: "" }
|
||||
: command[2] === `gui/501/${LABEL}.0123456789abcdef`
|
||||
? { code: 0, stdout: "\tpid = 999\n" }
|
||||
: { code: 113, stdout: "" };
|
||||
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "darwin",
|
||||
uid: 501,
|
||||
probe,
|
||||
markerEnabled: async () => true,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
state: "enabled",
|
||||
reason: "service-enabled",
|
||||
jobPid: 999,
|
||||
});
|
||||
});
|
||||
|
||||
it("is disabled under a launchctl disable override (the transient fallback)", async () => {
|
||||
const { root, dataDir } = await home();
|
||||
await writeLaunchAgent(root, dataDir);
|
||||
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "darwin",
|
||||
uid: 501,
|
||||
probe: launchctl({ disabled: true, job: { code: 113 } }),
|
||||
markerEnabled: async () => true,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
state: "disabled",
|
||||
reason: "service-disabled-by-launchctl",
|
||||
jobPid: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("is disabled when Launch at login is off, even with the job still loaded", async () => {
|
||||
const { root, dataDir } = await home();
|
||||
await writeLaunchAgent(root, dataDir);
|
||||
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "darwin",
|
||||
uid: 501,
|
||||
probe: launchctl({}),
|
||||
markerEnabled: async () => false,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
state: "disabled",
|
||||
reason: "service-enable-marker-absent",
|
||||
jobPid: 4321,
|
||||
});
|
||||
});
|
||||
|
||||
it("stays enabled for an installed job launchd has not loaded yet", async () => {
|
||||
const { root, dataDir } = await home();
|
||||
await writeLaunchAgent(root, dataDir);
|
||||
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "darwin",
|
||||
uid: 501,
|
||||
probe: launchctl({ job: { code: 113 } }),
|
||||
markerEnabled: async () => true,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
state: "enabled",
|
||||
reason: "service-enabled",
|
||||
jobPid: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the override table cannot be read", launchctl({ overridesCode: 1 })],
|
||||
["the job query fails", launchctl({ job: { code: 5 } })],
|
||||
[
|
||||
"launchctl cannot run",
|
||||
(async () => {
|
||||
throw new Error("spawn failed");
|
||||
}) as ServiceStateProbe,
|
||||
],
|
||||
])("is unknown when %s", async (_case, probe) => {
|
||||
const { root, dataDir } = await home();
|
||||
await writeLaunchAgent(root, dataDir);
|
||||
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "darwin",
|
||||
uid: 501,
|
||||
probe,
|
||||
markerEnabled: async () => true,
|
||||
}),
|
||||
).resolves.toMatchObject({ state: "unknown" });
|
||||
});
|
||||
|
||||
it("is unknown when the query outlives its budget", async () => {
|
||||
const { root, dataDir } = await home();
|
||||
await writeLaunchAgent(root, dataDir);
|
||||
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "darwin",
|
||||
uid: 501,
|
||||
timeoutMs: 20,
|
||||
probe: () => new Promise(() => {}),
|
||||
markerEnabled: async () => true,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
state: "unknown",
|
||||
reason: "service-state-timeout",
|
||||
jobPid: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("is unknown without a user id to name the launchd domain", async () => {
|
||||
const { root, dataDir } = await home();
|
||||
await writeLaunchAgent(root, dataDir);
|
||||
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "darwin",
|
||||
probe: launchctl({}),
|
||||
markerEnabled: async () => true,
|
||||
}),
|
||||
).resolves.toMatchObject({ state: "unknown" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("asking systemd whether the background service is on", () => {
|
||||
async function writeUnit(root: string, dataDir: string) {
|
||||
const directory = path.join(root, ".config", "systemd", "user");
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(directory, "one-memory.service"),
|
||||
`[Service]\nEnvironment="ONE_MEMORY_DATA_DIR=${dataDir}"\n`,
|
||||
);
|
||||
}
|
||||
const systemctl =
|
||||
(answer: string, code = 0): ServiceStateProbe =>
|
||||
async (command) =>
|
||||
command.includes("is-enabled")
|
||||
? { code, stdout: `${answer}\n` }
|
||||
: { code: 0, stdout: "2468\n" };
|
||||
|
||||
it("follows systemctl is-enabled", async () => {
|
||||
const { root, dataDir } = await home();
|
||||
await writeUnit(root, dataDir);
|
||||
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "linux",
|
||||
probe: systemctl("enabled"),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
state: "enabled",
|
||||
reason: "service-enabled",
|
||||
jobPid: 2468,
|
||||
});
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "linux",
|
||||
probe: systemctl("disabled", 1),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
state: "disabled",
|
||||
reason: "service-disabled-by-systemd",
|
||||
});
|
||||
});
|
||||
|
||||
it("is disabled without a unit and unknown without an answer", async () => {
|
||||
const { root, dataDir } = await home();
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "linux",
|
||||
probe: systemctl("enabled"),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
state: "disabled",
|
||||
reason: "service-not-installed",
|
||||
});
|
||||
await writeUnit(root, dataDir);
|
||||
await expect(
|
||||
queryRuntimeServiceState({
|
||||
dataDir,
|
||||
home: root,
|
||||
platform: "linux",
|
||||
probe: systemctl("", 1),
|
||||
}),
|
||||
).resolves.toMatchObject({ state: "unknown" });
|
||||
});
|
||||
});
|
||||
|
||||
it("reads only the job's own top-level pid", () => {
|
||||
expect(parseLaunchctlPid("x = {\n\tpid = 77\n\tendpoints = {\n}\n")).toBe(77);
|
||||
expect(parseLaunchctlPid("x = {\n\tstate = waiting\n}\n")).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { readdir, readFile, realpath } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { parseDisabledOverride } from "./service-install.js";
|
||||
import { runtimeServiceEnabled } from "./service-ownership.js";
|
||||
import { RUNTIME_SERVICE_ID } from "./service-plan.js";
|
||||
|
||||
/**
|
||||
* Whether the background service would keep a Runtime for this data dir alive
|
||||
* without the App.
|
||||
*
|
||||
* `enabled`: a LaunchAgent (or systemd user unit) declaring this data dir is
|
||||
* installed, the OS has no disable override for it, and on macOS the enable
|
||||
* marker its `KeepAlive` waits on is present. The marker is the App's "Launch
|
||||
* at login" switch and `memhub service disable`: without it launchd neither
|
||||
* starts nor restarts the job, so the service is off even while the plist is
|
||||
* still installed.
|
||||
*
|
||||
* `disabled`: none of that holds. The service was never installed or was
|
||||
* uninstalled, `launchctl disable` / `systemctl disable` turned it off (the
|
||||
* case in which the App falls back to a transient Runtime), or the enable
|
||||
* marker is gone.
|
||||
*
|
||||
* `unknown`: a query failed or timed out. Callers must not act on it.
|
||||
*/
|
||||
export type RuntimeServiceStateName = "enabled" | "disabled" | "unknown";
|
||||
|
||||
export type RuntimeServiceStateReason =
|
||||
| "service-enabled"
|
||||
| "service-not-installed"
|
||||
| "service-enable-marker-absent"
|
||||
| "service-disabled-by-launchctl"
|
||||
| "service-disabled-by-systemd"
|
||||
| "service-unsupported-platform"
|
||||
| "service-state-query-failed"
|
||||
| "service-state-timeout";
|
||||
|
||||
export interface RuntimeServiceState {
|
||||
state: RuntimeServiceStateName;
|
||||
reason: RuntimeServiceStateReason;
|
||||
/** The pid the service manager reports for the running job: null when the
|
||||
* job is not loaded, not running, or the state is unknown. */
|
||||
jobPid: number | null;
|
||||
}
|
||||
|
||||
export type ServiceStateProbe = (
|
||||
command: readonly string[],
|
||||
timeoutMs: number,
|
||||
) => Promise<{ code: number | null; stdout: string }>;
|
||||
|
||||
export interface RuntimeServiceStateOptions {
|
||||
dataDir: string;
|
||||
home: string;
|
||||
platform?: NodeJS.Platform;
|
||||
uid?: number;
|
||||
xdgConfigHome?: string;
|
||||
/** Budget for the whole query; past it the state is `unknown`. */
|
||||
timeoutMs?: number;
|
||||
probe?: ServiceStateProbe;
|
||||
markerEnabled?: (dataDir: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const LAUNCH_AGENT_FILE = new RegExp(
|
||||
`^${RUNTIME_SERVICE_ID.replaceAll(".", "\\.")}(?:\\.[0-9a-f]{16})?\\.plist$`,
|
||||
);
|
||||
|
||||
/** Ask the service manager, now, whether the background service is on.
|
||||
*
|
||||
* Never throws and never caches: every answer is a fresh `launchctl` /
|
||||
* `systemctl` read, and any failure or timeout is `unknown`.
|
||||
*/
|
||||
export async function queryRuntimeServiceState(
|
||||
options: RuntimeServiceStateOptions,
|
||||
): Promise<RuntimeServiceState> {
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<RuntimeServiceState>((resolve) => {
|
||||
timer = setTimeout(
|
||||
() => resolve(state("unknown", "service-state-timeout")),
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([
|
||||
inspect(options, timeoutMs).catch(() =>
|
||||
state("unknown", "service-state-query-failed"),
|
||||
),
|
||||
timeout,
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function inspect(
|
||||
options: RuntimeServiceStateOptions,
|
||||
timeoutMs: number,
|
||||
): Promise<RuntimeServiceState> {
|
||||
const platform = options.platform ?? process.platform;
|
||||
const probe = options.probe ?? runProbe;
|
||||
if (platform === "darwin")
|
||||
return inspectLaunchAgent(options, probe, timeoutMs);
|
||||
if (platform === "linux")
|
||||
return inspectSystemdUnit(options, probe, timeoutMs);
|
||||
return state("disabled", "service-unsupported-platform");
|
||||
}
|
||||
|
||||
async function inspectLaunchAgent(
|
||||
options: RuntimeServiceStateOptions,
|
||||
probe: ServiceStateProbe,
|
||||
timeoutMs: number,
|
||||
): Promise<RuntimeServiceState> {
|
||||
const uid = options.uid;
|
||||
if (uid === undefined || !Number.isSafeInteger(uid) || uid < 1) {
|
||||
return state("unknown", "service-state-query-failed");
|
||||
}
|
||||
const labels = await launchAgentsServing(options.home, options.dataDir);
|
||||
if (labels.length === 0) return state("disabled", "service-not-installed");
|
||||
const domain = `gui/${uid}`;
|
||||
const overrides = await probe(
|
||||
["launchctl", "print-disabled", domain],
|
||||
timeoutMs,
|
||||
);
|
||||
if (overrides.code !== 0) {
|
||||
return state("unknown", "service-state-query-failed");
|
||||
}
|
||||
const marker = await (options.markerEnabled ?? runtimeServiceEnabled)(
|
||||
options.dataDir,
|
||||
);
|
||||
let first: RuntimeServiceState | null = null;
|
||||
for (const label of labels) {
|
||||
const job = await probe(
|
||||
["launchctl", "print", `${domain}/${label}`],
|
||||
timeoutMs,
|
||||
);
|
||||
// 113: the label is not loaded. Anything else unexpected is unknown.
|
||||
if (job.code !== 0 && job.code !== 113) {
|
||||
return state("unknown", "service-state-query-failed");
|
||||
}
|
||||
const jobPid = job.code === 0 ? parseLaunchctlPid(job.stdout) : null;
|
||||
const current = parseDisabledOverride(overrides.stdout, label)
|
||||
? state("disabled", "service-disabled-by-launchctl", jobPid)
|
||||
: marker
|
||||
? state("enabled", "service-enabled", jobPid)
|
||||
: state("disabled", "service-enable-marker-absent", jobPid);
|
||||
if (current.state === "enabled") return current;
|
||||
first ??= current;
|
||||
}
|
||||
return first ?? state("disabled", "service-not-installed");
|
||||
}
|
||||
|
||||
/** Labels of the MemHub LaunchAgents whose declaration serves `dataDir`.
|
||||
* The daily install and isolated install roots use different labels, and a
|
||||
* LaunchAgent for another data dir is not this data dir's service. */
|
||||
async function launchAgentsServing(
|
||||
home: string,
|
||||
dataDir: string,
|
||||
): Promise<string[]> {
|
||||
const directory = path.join(home, "Library", "LaunchAgents");
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(directory);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
||||
throw error;
|
||||
}
|
||||
const wanted = await canonical(dataDir);
|
||||
const labels: string[] = [];
|
||||
for (const name of names
|
||||
.filter((item) => LAUNCH_AGENT_FILE.test(item))
|
||||
.sort()) {
|
||||
const label = name.slice(0, -".plist".length);
|
||||
const content = await readFile(path.join(directory, name), "utf8");
|
||||
if (!content.includes(`<key>Label</key><string>${label}</string>`))
|
||||
continue;
|
||||
const declared =
|
||||
/<key>ONE_MEMORY_DATA_DIR<\/key><string>([^<]*)<\/string>/.exec(
|
||||
content,
|
||||
)?.[1];
|
||||
if (declared === undefined) continue;
|
||||
if ((await canonical(unescapeXml(declared))) === wanted) labels.push(label);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
async function inspectSystemdUnit(
|
||||
options: RuntimeServiceStateOptions,
|
||||
probe: ServiceStateProbe,
|
||||
timeoutMs: number,
|
||||
): Promise<RuntimeServiceState> {
|
||||
const config = path.resolve(
|
||||
options.xdgConfigHome?.trim() || path.join(options.home, ".config"),
|
||||
);
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(
|
||||
path.join(config, "systemd", "user", "one-memory.service"),
|
||||
"utf8",
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return state("disabled", "service-not-installed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const declared =
|
||||
/^Environment="ONE_MEMORY_DATA_DIR=((?:[^"\\]|\\.)*)"$/m.exec(content)?.[1];
|
||||
if (
|
||||
declared === undefined ||
|
||||
(await canonical(declared.replace(/\\(.)/g, "$1"))) !==
|
||||
(await canonical(options.dataDir))
|
||||
) {
|
||||
return state("disabled", "service-not-installed");
|
||||
}
|
||||
const enabled = await probe(
|
||||
["systemctl", "--user", "is-enabled", "one-memory.service"],
|
||||
timeoutMs,
|
||||
);
|
||||
const answer = enabled.stdout.trim().split(/\r?\n/)[0] ?? "";
|
||||
if (enabled.code === null || answer === "") {
|
||||
return state("unknown", "service-state-query-failed");
|
||||
}
|
||||
const main = await probe(
|
||||
[
|
||||
"systemctl",
|
||||
"--user",
|
||||
"show",
|
||||
"one-memory.service",
|
||||
"--property=MainPID",
|
||||
"--value",
|
||||
],
|
||||
timeoutMs,
|
||||
);
|
||||
const pid = Number(main.stdout.trim());
|
||||
const jobPid =
|
||||
main.code === 0 && Number.isSafeInteger(pid) && pid > 1 ? pid : null;
|
||||
if (answer === "enabled" || answer === "enabled-runtime") {
|
||||
return state("enabled", "service-enabled", jobPid);
|
||||
}
|
||||
return state("disabled", "service-disabled-by-systemd", jobPid);
|
||||
}
|
||||
|
||||
/** The top-level `pid = N` launchctl prints for a running job. Top-level
|
||||
* properties are indented by exactly one tab; nested blocks (endpoints,
|
||||
* spawn info) carry their own `pid` lines and must not be read as the job's. */
|
||||
export function parseLaunchctlPid(stdout: string): number | null {
|
||||
for (const line of stdout.split(/\r?\n/)) {
|
||||
const match = /^\tpid = (\d+)\s*$/.exec(line);
|
||||
if (!match) continue;
|
||||
const pid = Number(match[1]);
|
||||
return Number.isSafeInteger(pid) && pid > 1 ? pid : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function state(
|
||||
name: RuntimeServiceStateName,
|
||||
reason: RuntimeServiceStateReason,
|
||||
jobPid: number | null = null,
|
||||
): RuntimeServiceState {
|
||||
return { state: name, reason, jobPid };
|
||||
}
|
||||
|
||||
async function canonical(target: string): Promise<string> {
|
||||
const resolved = path.resolve(target);
|
||||
return realpath(resolved).catch(() => resolved);
|
||||
}
|
||||
|
||||
function unescapeXml(value: string): string {
|
||||
return value
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll(""", '"')
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("&", "&");
|
||||
}
|
||||
|
||||
async function runProbe(
|
||||
command: readonly string[],
|
||||
timeoutMs: number,
|
||||
): Promise<{ code: number | null; stdout: string }> {
|
||||
const [program, ...args] = command;
|
||||
if (!program) throw new Error("runtime_service_inspection_failed");
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(program, args, {
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error("runtime_service_inspection_timeout"));
|
||||
}, timeoutMs);
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > 256 * 1024) {
|
||||
child.kill();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
child.once("error", () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("runtime_service_inspection_failed"));
|
||||
});
|
||||
child.once("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (size > 256 * 1024) {
|
||||
reject(new Error("runtime_service_inspection_failed"));
|
||||
return;
|
||||
}
|
||||
resolve({ code, stdout: Buffer.concat(chunks).toString("utf8") });
|
||||
});
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hy-memory"
|
||||
version = "2.0.11"
|
||||
version = "2.0.12"
|
||||
description = "HY Memory - Industrial-grade dual-system cognitive memory framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Intention(L7)读侧判重开关(issue #241)。
|
||||
|
||||
覆盖:
|
||||
1. 默认开启:行为不变,复本只返回最早一条,其余异步删库;
|
||||
2. 关闭:不隐藏、不删除,两条意图都返回;
|
||||
3. 阈值生效:cosine 低于阈值不判重,高于阈值判重;
|
||||
4. reader 按 client 的 MemoryConfig.recall 读取开关/阈值,而非调用时的 env;
|
||||
5. MemoryConfig 默认值与 env 回退;
|
||||
6. 写入侧批内判重同样受开关控制;阈值不影响写入侧;
|
||||
7. 关闭时不为判重补拉 embedding;配置阈值非法时不影响召回。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from hy_memory.config import MemoryConfig
|
||||
from hy_memory.pipelines.base import ReadRequest
|
||||
from hy_memory.pipelines._retrieval.intention import recall_intentions
|
||||
|
||||
from tests.test_intention_dedup import (
|
||||
_FakeVectorStore,
|
||||
_make_intention_node,
|
||||
_run,
|
||||
)
|
||||
|
||||
|
||||
_MA = datetime(2025, 8, 6)
|
||||
_VU = datetime(2025, 8, 31, 23, 59)
|
||||
_NOW = datetime(2025, 8, 6, 12, 0)
|
||||
|
||||
|
||||
def _unit(vec):
|
||||
n = sum(x * x for x in vec) ** 0.5 or 1.0
|
||||
return [x / n for x in vec]
|
||||
|
||||
|
||||
def _seed_project_pair(vs, *, emb_a, emb_b):
|
||||
"""A / B 两个项目的近似意图:同 user/agent/同天/同截止日。A 更早写入。"""
|
||||
a = _make_intention_node(
|
||||
"A 项目每周一触发发布前校验流程", memory_at=_MA, valid_until=_VU,
|
||||
gmt_created=datetime(2025, 8, 1), embedding=emb_a,
|
||||
)
|
||||
b = _make_intention_node(
|
||||
"B 项目每周一触发发布前校验流程", memory_at=_MA, valid_until=_VU,
|
||||
gmt_created=datetime(2025, 8, 2), embedding=emb_b,
|
||||
)
|
||||
_run(vs.upsert(a))
|
||||
_run(vs.upsert(b))
|
||||
return a, b
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# recall_intentions 参数
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_default_on_keeps_earliest_and_deletes_duplicate():
|
||||
vs = _FakeVectorStore()
|
||||
emb = _unit([1.0, 0.0])
|
||||
a, b = _seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
|
||||
stats = {}
|
||||
result = _run(recall_intentions(
|
||||
vs, emb, user_ids=["u1"], limit=10, now=_NOW, stats_out=stats,
|
||||
))
|
||||
assert [h["node_id"] for h in result] == [a.node_id]
|
||||
assert vs.delete_calls == [b.node_id]
|
||||
assert stats["dedup_enabled"] is True
|
||||
assert stats["dedup_dropped"] == 1
|
||||
|
||||
|
||||
def test_off_returns_both_and_deletes_nothing():
|
||||
vs = _FakeVectorStore()
|
||||
emb = _unit([1.0, 0.0])
|
||||
a, b = _seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
|
||||
stats = {}
|
||||
result = _run(recall_intentions(
|
||||
vs, emb, user_ids=["u1"], limit=10, now=_NOW, stats_out=stats,
|
||||
dedup_enabled=False,
|
||||
))
|
||||
assert {h["node_id"] for h in result} == {a.node_id, b.node_id}
|
||||
assert vs.delete_calls == []
|
||||
assert stats["dedup_enabled"] is False
|
||||
assert stats["dedup_dropped"] == 0
|
||||
|
||||
|
||||
def test_threshold_respected():
|
||||
# cosine(a, b) = 0.9
|
||||
emb_a = _unit([1.0, 0.0])
|
||||
emb_b = _unit([0.9, (1 - 0.81) ** 0.5])
|
||||
|
||||
# 默认 0.95 → 0.9 不算复本
|
||||
vs = _FakeVectorStore()
|
||||
_seed_project_pair(vs, emb_a=emb_a, emb_b=emb_b)
|
||||
result = _run(recall_intentions(
|
||||
vs, emb_a, user_ids=["u1"], limit=10, now=_NOW, dedup_threshold=0.95,
|
||||
))
|
||||
assert len(result) == 2
|
||||
assert vs.delete_calls == []
|
||||
|
||||
# 调低到 0.85 → 判重,删较晚的 B
|
||||
vs = _FakeVectorStore()
|
||||
a, b = _seed_project_pair(vs, emb_a=emb_a, emb_b=emb_b)
|
||||
stats = {}
|
||||
result = _run(recall_intentions(
|
||||
vs, emb_a, user_ids=["u1"], limit=10, now=_NOW,
|
||||
dedup_threshold=0.85, stats_out=stats,
|
||||
))
|
||||
assert [h["node_id"] for h in result] == [a.node_id]
|
||||
assert vs.delete_calls == [b.node_id]
|
||||
assert stats["dedup_threshold"] == 0.85
|
||||
|
||||
|
||||
def test_explicit_threshold_wins_over_env(monkeypatch):
|
||||
monkeypatch.setenv("MEMORY_DEDUP_THRESHOLD", "0.5")
|
||||
emb_a = _unit([1.0, 0.0])
|
||||
emb_b = _unit([0.9, (1 - 0.81) ** 0.5])
|
||||
vs = _FakeVectorStore()
|
||||
_seed_project_pair(vs, emb_a=emb_a, emb_b=emb_b)
|
||||
result = _run(recall_intentions(
|
||||
vs, emb_a, user_ids=["u1"], limit=10, now=_NOW, dedup_threshold=0.95,
|
||||
))
|
||||
assert len(result) == 2
|
||||
assert vs.delete_calls == []
|
||||
|
||||
|
||||
def test_off_skips_embedding_backfill():
|
||||
vs = _FakeVectorStore()
|
||||
emb = _unit([1.0, 0.0])
|
||||
_seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
|
||||
|
||||
backfill_calls = []
|
||||
|
||||
async def _get_embeddings(ids):
|
||||
backfill_calls.append(ids)
|
||||
return {}
|
||||
|
||||
vs.get_embeddings = _get_embeddings
|
||||
for n in vs._nodes.values():
|
||||
n.embedding = None
|
||||
result = _run(recall_intentions(
|
||||
vs, emb, user_ids=["u1"], limit=10, now=_NOW, dedup_enabled=False,
|
||||
))
|
||||
assert len(result) == 2
|
||||
assert backfill_calls == []
|
||||
|
||||
|
||||
def test_invalid_threshold_degrades_to_env_default(monkeypatch):
|
||||
monkeypatch.delenv("MEMORY_DEDUP_THRESHOLD", raising=False)
|
||||
vs = _FakeVectorStore()
|
||||
emb = _unit([1.0, 0.0])
|
||||
a, b = _seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
|
||||
stats = {}
|
||||
result = _run(recall_intentions(
|
||||
vs, emb, user_ids=["u1"], limit=10, now=_NOW, stats_out=stats,
|
||||
dedup_threshold="not-a-number",
|
||||
))
|
||||
assert [h["node_id"] for h in result] == [a.node_id]
|
||||
assert stats["dedup_threshold"] == 0.95
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 写入侧批内判重
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class _SameVectorEmbed:
|
||||
"""所有文本同一向量:cosine=1,开启判重时必判为复本。"""
|
||||
|
||||
async def embed_batch(self, texts):
|
||||
return [_unit([1.0, 0.0]) for _ in texts]
|
||||
|
||||
async def embed_queued(self, text):
|
||||
return _unit([1.0, 0.0])
|
||||
|
||||
|
||||
def _store_batch(config_recall):
|
||||
from hy_memory.pipelines.base import WriteRequest
|
||||
from hy_memory.pipelines.writer import MemoryWriter
|
||||
|
||||
cfg = MemoryConfig.from_dict({"recall": config_recall})
|
||||
vs = _FakeVectorStore()
|
||||
writer = MemoryWriter(cfg, embed_service=_SameVectorEmbed(), vector_store=vs)
|
||||
req = WriteRequest(
|
||||
content="", messages=[], user_id="u1", agent_id="a1",
|
||||
session_id="s1", request_id=uuid.uuid4().hex,
|
||||
)
|
||||
intentions = [
|
||||
{"content": "A 项目每周一触发发布前校验流程", "tags": [], "valid_until": None},
|
||||
{"content": "B 项目每周一触发发布前校验流程", "tags": [], "valid_until": None},
|
||||
]
|
||||
return _run(writer._store_intentions(intentions, req, vs, req.request_id)), vs
|
||||
|
||||
|
||||
def test_write_batch_dedup_default_on_unchanged():
|
||||
ids, vs = _store_batch({})
|
||||
assert len(ids) == 1
|
||||
assert [n.content for n in vs._nodes.values()] == [
|
||||
"A 项目每周一触发发布前校验流程",
|
||||
]
|
||||
|
||||
|
||||
def test_write_batch_dedup_off_stores_both():
|
||||
ids, vs = _store_batch({"intention_dedup_enabled": False})
|
||||
assert len(ids) == 2
|
||||
assert len(vs._nodes) == 2
|
||||
|
||||
|
||||
def test_read_threshold_does_not_change_write_batch_dedup(monkeypatch):
|
||||
"""intention_dedup_threshold 只管读侧;写入侧仍取 MEMORY_DEDUP_THRESHOLD。"""
|
||||
monkeypatch.delenv("MEMORY_DEDUP_THRESHOLD", raising=False)
|
||||
ids, _ = _store_batch({"intention_dedup_threshold": 1.5})
|
||||
assert len(ids) == 1
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MemoryConfig
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_config_defaults(monkeypatch):
|
||||
for k in (
|
||||
"MEMORY_INTENTION_DEDUP_ENABLED",
|
||||
"MEMORY_INTENTION_DEDUP_THRESHOLD",
|
||||
"MEMORY_DEDUP_THRESHOLD",
|
||||
):
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
cfg = MemoryConfig()
|
||||
assert cfg.recall.intention_dedup_enabled is True
|
||||
assert cfg.recall.intention_dedup_threshold == 0.95
|
||||
|
||||
|
||||
def test_config_env_fallbacks(monkeypatch):
|
||||
monkeypatch.setenv("MEMORY_INTENTION_DEDUP_ENABLED", "false")
|
||||
monkeypatch.setenv("MEMORY_DEDUP_THRESHOLD", "0.9")
|
||||
monkeypatch.delenv("MEMORY_INTENTION_DEDUP_THRESHOLD", raising=False)
|
||||
cfg = MemoryConfig()
|
||||
assert cfg.recall.intention_dedup_enabled is False
|
||||
# 未设专用阈值 → 回退共享 MEMORY_DEDUP_THRESHOLD(兼容旧部署)
|
||||
assert cfg.recall.intention_dedup_threshold == 0.9
|
||||
|
||||
monkeypatch.setenv("MEMORY_INTENTION_DEDUP_THRESHOLD", "0.8")
|
||||
assert MemoryConfig().recall.intention_dedup_threshold == 0.8
|
||||
|
||||
|
||||
def test_config_from_dict_overrides_env(monkeypatch):
|
||||
monkeypatch.setenv("MEMORY_INTENTION_DEDUP_ENABLED", "true")
|
||||
cfg = MemoryConfig.from_dict({"recall": {
|
||||
"intention_dedup_enabled": False,
|
||||
"intention_dedup_threshold": 0.99,
|
||||
}})
|
||||
assert cfg.recall.intention_dedup_enabled is False
|
||||
assert cfg.recall.intention_dedup_threshold == 0.99
|
||||
assert cfg.to_dict()["recall"]["intention_dedup_enabled"] is False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# reader:按 client 配置,而不是调用时的 env
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _reader_for(enabled: bool, vs):
|
||||
from hy_memory.pipelines.reader_hybrid import HybridReadPipeline
|
||||
|
||||
cfg = MemoryConfig.from_dict({
|
||||
"vector_store": {
|
||||
"provider": "chroma",
|
||||
"collection_name": f"t_intent_sw_{uuid.uuid4().hex[:8]}",
|
||||
"embedding_dims": 2,
|
||||
},
|
||||
"recall": {"intention_dedup_enabled": enabled},
|
||||
})
|
||||
reader = HybridReadPipeline(cfg, vector_store=vs)
|
||||
reader._vector_store = vs
|
||||
reader._vector_store_initialized = True
|
||||
return reader
|
||||
|
||||
|
||||
async def _no_keyword_hits(**kw):
|
||||
return []
|
||||
|
||||
|
||||
def _read_project_pair(enabled: bool):
|
||||
"""经 reader 完整读路径召回 A/B 两条近似意图;返回 (返回的意图 id, 删库调用, a, b)。"""
|
||||
vs = _FakeVectorStore()
|
||||
vs.keyword_search = _no_keyword_hits
|
||||
emb = _unit([1.0, 0.0])
|
||||
a, b = _seed_project_pair(vs, emb_a=emb, emb_b=list(emb))
|
||||
# 读路径用真实 now:让两条都未过期且同一天
|
||||
today = datetime.now()
|
||||
for n in (a, b):
|
||||
n.memory_at = today
|
||||
n.valid_until = None
|
||||
reader = _reader_for(enabled, vs)
|
||||
_run(reader.initialize())
|
||||
resp = _run(reader.read(ReadRequest(
|
||||
query="发布前校验", query_embedding=emb,
|
||||
user_ids=["u1"], agent_ids=["a1"], limit=10, intention_limit=5,
|
||||
)))
|
||||
ids = {
|
||||
m["memory_id"] for m in resp.memories
|
||||
if m.get("layer") == "l7_intention"
|
||||
}
|
||||
return ids, list(vs.delete_calls), a, b
|
||||
|
||||
|
||||
@pytest.mark.parametrize("env_value", ["true", "false"])
|
||||
def test_reader_uses_per_client_config_not_env(monkeypatch, env_value):
|
||||
"""调用时的 env 与 client 配置无关:两个 client 一开一关,各自生效。"""
|
||||
monkeypatch.setenv("MEMORY_INTENTION_DEDUP_ENABLED", env_value)
|
||||
monkeypatch.setenv("MEMORY_DEDUP_THRESHOLD", "0.0")
|
||||
|
||||
ids_on, deleted_on, a, b = _read_project_pair(True)
|
||||
assert ids_on == {a.node_id}
|
||||
assert deleted_on == [b.node_id]
|
||||
|
||||
ids_off, deleted_off, a, b = _read_project_pair(False)
|
||||
assert ids_off == {a.node_id, b.node_id}
|
||||
assert deleted_off == []
|
||||
|
||||
|
||||
def test_reader_passes_client_threshold(monkeypatch):
|
||||
monkeypatch.setenv("MEMORY_DEDUP_THRESHOLD", "0.1")
|
||||
seen = []
|
||||
|
||||
async def _spy(*args, **kwargs):
|
||||
seen.append(kwargs)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("hy_memory.pipelines.reader_base.recall_intentions", _spy)
|
||||
vs = _FakeVectorStore()
|
||||
vs.keyword_search = _no_keyword_hits
|
||||
reader = _reader_for(True, vs)
|
||||
reader.config.recall.intention_dedup_threshold = 0.97
|
||||
_run(reader.initialize())
|
||||
_run(reader.read(ReadRequest(
|
||||
query="发布前校验", query_embedding=_unit([1.0, 0.0]),
|
||||
user_ids=["u1"], agent_ids=["a1"], limit=10, intention_limit=5,
|
||||
)))
|
||||
assert seen and seen[0]["dedup_enabled"] is True
|
||||
assert seen[0]["dedup_threshold"] == 0.97
|
||||
Reference in new issue
Block a user