Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
686c14ed62 | ||
|
|
fcb1bcd866 | ||
|
|
6305e4cea8 | ||
|
|
a4b0969248 | ||
|
|
e0920311a1 | ||
|
|
e6426f6fd0 | ||
|
|
99e3c8d9d7 | ||
|
|
896a9c6c83 | ||
|
|
193fabeeb4 | ||
|
|
0aeb3e8760 | ||
|
|
70aaa7be85 | ||
|
|
18046152ab | ||
|
|
7b946a28ef | ||
|
|
0c36a6c960 |
No files matched your search
@@ -4,6 +4,19 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Reused the existing three-column memory map for SDK-authority list/detail
|
||||
views and removed the retired "original evidence" surface. SDK-native status,
|
||||
source, conversation time and creation time now render without fabricated
|
||||
revisions; source and text filters are applied by the paged Core endpoint.
|
||||
- SDK memory search now calls the SDK search contract instead of listing the
|
||||
entire library in Core. Corrupt empty records are skipped and diagnosed
|
||||
without mutating data or failing a page, and post-cutover metadata repair
|
||||
restores source and original times from the verified migration bundle.
|
||||
- Kept Cursor's WAL change token separate from the file mtime so a hash can no
|
||||
longer appear as a conversation date centuries in the future.
|
||||
- A trusted packaged Runtime now repairs a dirty same-build installation by
|
||||
staging a clean replacement, and `runtime_service_not_ready` is rendered as
|
||||
user-facing startup guidance instead of an internal code.
|
||||
- Fixed legacy-to-SDK startup migration to adopt native Coding Experience and
|
||||
Working Fact IDs instead of importing them as Chat layers. Raw migration now
|
||||
rebuilds bounded user/assistant turns from the latest three days, excludes
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
3. MemHub **不存 memory 实体**;**可以存 `memory_id`** 做组织/回执。原文进入 **SDK raw**。
|
||||
4. SDK raw 只保留滚动三天;一条 raw 对应一个完整 turn,只含真人 user 与
|
||||
assistant,tool 不持久化。提取后的高层 memory 不受该窗口影响。
|
||||
5. `source_raw_ids` 是 SDK 内部溯源关系,不是产品 Evidence 合同;记忆详情不提供
|
||||
“原始依据”或按 raw ID 展开原文的入口。
|
||||
|
||||
生产入口通过共享 Runtime 租约取得公开 `HyMemoryClient`;MemHub 只在调用前执行
|
||||
模型配置、密钥和 harness/model 门禁,不维护第二套 memory 正文权威。
|
||||
|
||||
@@ -3180,7 +3180,8 @@ async function runCommand(
|
||||
migration.code ?? "sdk_migration_incomplete",
|
||||
);
|
||||
}
|
||||
migrated = migration.state === "cutover";
|
||||
migrated =
|
||||
migration.state === "cutover" || migration.state === "complete";
|
||||
await applyPreparedServiceInstall(preparedService);
|
||||
serviceApplied = true;
|
||||
// The receipt exists so a later command can find the data dir without
|
||||
@@ -3665,7 +3666,8 @@ async function runCommand(
|
||||
}
|
||||
// An older Runtime refuses a migrated home, so from here a failure
|
||||
// keeps the new version instead of rolling back to one that cannot start.
|
||||
migrated = migration.state === "cutover";
|
||||
migrated =
|
||||
migration.state === "cutover" || migration.state === "complete";
|
||||
await restartUserService(layout, serviceOptions);
|
||||
// A restart that returns without error only means launchd/systemd
|
||||
// accepted the command. O-22: the first `upgrade` implementation
|
||||
|
||||
@@ -103,6 +103,7 @@ from one_memory.hy_sdk_types import (
|
||||
HySdkMemoryRecord,
|
||||
HySdkRawPage,
|
||||
HySdkRawRecord,
|
||||
HySdkReadError,
|
||||
HySdkUserProfile,
|
||||
parse_sdk_search,
|
||||
)
|
||||
@@ -879,6 +880,28 @@ def create_app(
|
||||
app.state.sdk_data_plane_migrations = SdkDataPlaneMigrationStore(database_path)
|
||||
app.state.memory_authority = app.state.sdk_data_plane_migrations.authority()
|
||||
if app.state.memory_authority is MemoryAuthority.SDK:
|
||||
from one_memory.hy_sdk_migration import (
|
||||
repair_sdk_metadata_from_bundle,
|
||||
)
|
||||
|
||||
try:
|
||||
repair = await asyncio.to_thread(
|
||||
repair_sdk_metadata_from_bundle,
|
||||
data_dir=runtime_settings.data_dir,
|
||||
database_path=database_path,
|
||||
)
|
||||
if repair.vector_rows or repair.coding_rows:
|
||||
logger.info(
|
||||
"sdk_metadata_repair_completed mapped=%d vector=%d coding=%d",
|
||||
repair.mapped,
|
||||
repair.vector_rows,
|
||||
repair.coding_rows,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"sdk_metadata_repair_failed error_type=%s",
|
||||
type(error).__name__,
|
||||
)
|
||||
app.state.memory_governance_store = RetiredGovernanceStore(database_path)
|
||||
app.state.evidence_store = RetiredEvidenceStore(database_path)
|
||||
else:
|
||||
@@ -3058,7 +3081,8 @@ def create_app(
|
||||
offset=0,
|
||||
order="desc",
|
||||
)
|
||||
page = HySdkMemoryPage.from_sdk(raw, limit=1, offset=0)
|
||||
page, skipped = HySdkMemoryPage.from_sdk_skipping_invalid(raw, limit=1, offset=0)
|
||||
_log_skipped_sdk_memories(skipped)
|
||||
except Exception as error:
|
||||
raise _sdk_http_error(error, "hy_memory_list_failed") from None
|
||||
return SdkHomeSummary(
|
||||
@@ -3103,7 +3127,10 @@ def create_app(
|
||||
if store is None:
|
||||
store = SdkDataPlaneMigrationStore(_evidence_store(app).database_path)
|
||||
authority, migration_id = store.authority_state()
|
||||
return MemoryAuthorityResponse(authority=authority.value, migration_id=migration_id)
|
||||
return MemoryAuthorityResponse(
|
||||
authority="sdk" if authority is MemoryAuthority.SDK else "legacy",
|
||||
migration_id=migration_id,
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/api/v2/memory-library",
|
||||
@@ -3112,22 +3139,207 @@ def create_app(
|
||||
)
|
||||
async def sdk_memory_library(
|
||||
statuses: list[str] = Query(default=["active"]), # noqa: B008
|
||||
source: str | None = Query(default=None, min_length=1),
|
||||
query: str | None = Query(default=None, max_length=512),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
) -> HySdkMemoryPage:
|
||||
try:
|
||||
client = await _sdk_client_handle(app).client()
|
||||
search_query = (query or "").strip()
|
||||
if search_query:
|
||||
if set(statuses) != {"active"}:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="sdk_search_supports_active_status_only",
|
||||
)
|
||||
# SDK search has no count-only contract. Fetch one bounded
|
||||
# relevance window so pagination can expose more than the
|
||||
# first page without listing the whole library in Core.
|
||||
fetch = 500
|
||||
search_raw = await asyncio.to_thread(
|
||||
client.search,
|
||||
search_query,
|
||||
user_ids=["local"],
|
||||
agent_ids=[],
|
||||
include_sources=[source] if source else None,
|
||||
limit=fetch,
|
||||
min_score=sdk_relevance_floor(),
|
||||
schema_min_score=sdk_relevance_floor(),
|
||||
schema_limit=fetch,
|
||||
# The library lists stored memories; proactive intentions
|
||||
# are a recall-time lane the list never shows.
|
||||
intention_limit=0,
|
||||
scene="normal",
|
||||
reader=HY_MEMORY_READER,
|
||||
)
|
||||
hits = parse_sdk_search(
|
||||
search_raw,
|
||||
error_code="hy_memory_search_shape_unknown",
|
||||
)
|
||||
rows = await _get_sdk_rows(
|
||||
client, [hit.memory_id for hit in hits[offset : offset + limit]]
|
||||
)
|
||||
memories: list[HySdkMemoryRecord] = []
|
||||
skipped = 0
|
||||
for row in rows:
|
||||
if row is None:
|
||||
continue
|
||||
try:
|
||||
record = HySdkMemoryRecord.from_sdk(row)
|
||||
except HySdkReadError:
|
||||
skipped += 1
|
||||
continue
|
||||
if record.status == "active":
|
||||
memories.append(record)
|
||||
_log_skipped_sdk_memories(skipped)
|
||||
return HySdkMemoryPage(
|
||||
memories=tuple(memories),
|
||||
total=len(hits),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
raw = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
user_id="local",
|
||||
statuses=list(statuses),
|
||||
include_sources=[source] if source else None,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
return HySdkMemoryPage.from_sdk(raw, limit=limit, offset=offset)
|
||||
page, skipped = HySdkMemoryPage.from_sdk_skipping_invalid(
|
||||
raw, limit=limit, offset=offset
|
||||
)
|
||||
_log_skipped_sdk_memories(skipped)
|
||||
return page
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise _sdk_http_error(error, "hy_memory_list_failed") from None
|
||||
|
||||
@app.get(
|
||||
"/api/v2/memories/graph",
|
||||
response_model=MemoryGraphResponse,
|
||||
dependencies=protected,
|
||||
)
|
||||
async def sdk_memory_graph(
|
||||
limit: int = Query(default=160, ge=1, le=300),
|
||||
) -> MemoryGraphResponse:
|
||||
"""The memory map on SDK authority: SDK memories plus the Kuzu overlay.
|
||||
|
||||
Memories come from the public client; only the overlay reads Kuzu
|
||||
(`hy_graph_projection`, the visualization exception). Shape matches
|
||||
the legacy map so the same view draws both.
|
||||
"""
|
||||
try:
|
||||
client = await _sdk_client_handle(app).client()
|
||||
raw = await asyncio.to_thread(
|
||||
client.list_memories, user_id="local", limit=limit, offset=0, order="desc"
|
||||
)
|
||||
page, skipped = HySdkMemoryPage.from_sdk_skipping_invalid(raw, limit=limit, offset=0)
|
||||
_log_skipped_sdk_memories(skipped)
|
||||
except Exception as error:
|
||||
raise _sdk_http_error(error, "hy_memory_list_failed") from None
|
||||
vdb = raw.get("vdb") if isinstance(raw, dict) else None
|
||||
total = vdb.get("total") if isinstance(vdb, dict) else None
|
||||
memories = [memory for memory in page.memories if memory.layer != "l1_raw"]
|
||||
catalog = await asyncio.to_thread(_source_catalog, app)
|
||||
nodes: dict[str, MemoryGraphNode] = {}
|
||||
edges: list[MemoryGraphEdge] = []
|
||||
sessions: dict[str, list[str]] = {}
|
||||
for memory in memories:
|
||||
memory_node_id = f"memory:{memory.memory_id}"
|
||||
nodes[memory_node_id] = MemoryGraphNode(
|
||||
node_id=memory_node_id,
|
||||
node_type="memory",
|
||||
label=memory.content,
|
||||
memory_id=memory.memory_id,
|
||||
kind=memory.layer,
|
||||
status=memory.status,
|
||||
)
|
||||
if memory.session_id:
|
||||
sessions.setdefault(memory.session_id, []).append(memory_node_id)
|
||||
if memory.source and memory.source != "default":
|
||||
source_node_id = f"source:{memory.source}"
|
||||
registration = catalog.get(memory.source)
|
||||
nodes[source_node_id] = MemoryGraphNode(
|
||||
node_id=source_node_id,
|
||||
node_type="source",
|
||||
label=(registration.descriptor.display_name if registration else memory.source),
|
||||
source_id=memory.source,
|
||||
)
|
||||
edges.append(
|
||||
MemoryGraphEdge(
|
||||
edge_id=f"{source_node_id}->{memory_node_id}",
|
||||
source_node_id=source_node_id,
|
||||
target_node_id=memory_node_id,
|
||||
relation="evidence_source",
|
||||
origin="provenance",
|
||||
)
|
||||
)
|
||||
for _session_id, node_ids in sorted(sessions.items()):
|
||||
for left, right in zip(node_ids, node_ids[1:], strict=False):
|
||||
edges.append(
|
||||
MemoryGraphEdge(
|
||||
edge_id=f"{left}->{right}:same_conversation",
|
||||
source_node_id=left,
|
||||
target_node_id=right,
|
||||
relation="same_conversation",
|
||||
origin="same_conversation",
|
||||
)
|
||||
)
|
||||
hy_graph = None
|
||||
# Aggregate nodes carry no per-source provenance yet, so a revoked
|
||||
# source withholds the overlay, as on the legacy map.
|
||||
if not await asyncio.to_thread(_read_access(app).has_revoked_sources):
|
||||
try:
|
||||
hy_graph = await _sdk_memory_runtime(app).graph_snapshot(limit=limit)
|
||||
except HySdkError:
|
||||
hy_graph = None
|
||||
if hy_graph is not None:
|
||||
for node in hy_graph.nodes:
|
||||
node_id = f"memory:{node.node_id}"
|
||||
if node_id in nodes:
|
||||
continue
|
||||
node_id = f"hy:{node.node_id}"
|
||||
nodes.setdefault(
|
||||
node_id,
|
||||
MemoryGraphNode(
|
||||
node_id=node_id,
|
||||
node_type="topic" if node.layer == "TOPIC" else "schema",
|
||||
label=node.content,
|
||||
confidence=node.confidence,
|
||||
),
|
||||
)
|
||||
for edge in hy_graph.edges:
|
||||
source_node_id, target_node_id = (
|
||||
f"memory:{edge.source_node_id}"
|
||||
if f"memory:{edge.source_node_id}" in nodes
|
||||
else f"hy:{edge.source_node_id}",
|
||||
f"memory:{edge.target_node_id}"
|
||||
if f"memory:{edge.target_node_id}" in nodes
|
||||
else f"hy:{edge.target_node_id}",
|
||||
)
|
||||
if source_node_id not in nodes or target_node_id not in nodes:
|
||||
continue
|
||||
edges.append(
|
||||
MemoryGraphEdge(
|
||||
edge_id=f"{source_node_id}->{target_node_id}:{edge.relation}",
|
||||
source_node_id=source_node_id,
|
||||
target_node_id=target_node_id,
|
||||
relation=edge.relation,
|
||||
weight=edge.weight,
|
||||
origin="hy_relation",
|
||||
)
|
||||
)
|
||||
return MemoryGraphResponse(
|
||||
total_memories=total if isinstance(total, int) else len(memories),
|
||||
projected_memories=len(memories),
|
||||
nodes=tuple(nodes.values()),
|
||||
edges=tuple(edges),
|
||||
hy_graph_available=hy_graph is not None and hy_graph.consulted,
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/api/v2/user-profile",
|
||||
response_model=SdkUserProfileResponse,
|
||||
@@ -5431,6 +5643,26 @@ def _sdk_client_handle(app: FastAPI) -> SdkClientHandle:
|
||||
return handle
|
||||
|
||||
|
||||
def _log_skipped_sdk_memories(count: int) -> None:
|
||||
if count:
|
||||
logger.warning("sdk_memory_invalid_rows_skipped count=%d", count)
|
||||
|
||||
|
||||
# `client.get` funnels into the SDK's single loop thread, so more workers only
|
||||
# queue there while holding the default executor every other request needs.
|
||||
_SDK_GET_CONCURRENCY = 8
|
||||
|
||||
|
||||
async def _get_sdk_rows(client: Any, memory_ids: list[str]) -> list[object]:
|
||||
gate = asyncio.Semaphore(_SDK_GET_CONCURRENCY)
|
||||
|
||||
async def one(memory_id: str) -> object:
|
||||
async with gate:
|
||||
return await asyncio.to_thread(client.get, memory_id)
|
||||
|
||||
return list(await asyncio.gather(*(one(memory_id) for memory_id in memory_ids)))
|
||||
|
||||
|
||||
async def _get_sdk_memory(
|
||||
handle: SdkClientHandle,
|
||||
memory_id: str,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
"""Offline snapshot and restore of the HY SDK data plane for the App migration.
|
||||
"""Offline maintenance of the HY SDK data plane for App migrations.
|
||||
|
||||
Runtime product code must use public ``HyMemoryClient`` methods. The startup
|
||||
migration bundle is the isolated exception: all writers are stopped, so it may
|
||||
copy and restore the SDK-owned local stores as files.
|
||||
migration boundary is the isolated exception: all writers are stopped, so it
|
||||
may copy, restore and repair SDK-owned local stores.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from collections.abc import Iterator
|
||||
from contextlib import closing
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from hy_memory import ( # type: ignore[import-untyped]
|
||||
@@ -16,6 +22,27 @@ from hy_memory import ( # type: ignore[import-untyped]
|
||||
snapshot_local_data_plane,
|
||||
)
|
||||
|
||||
from one_memory.database_access import main_database
|
||||
from one_memory.hy_runtime_config import HY_MEMORY_NAMESPACE
|
||||
|
||||
_METADATA_REPAIR_VERSION = 1
|
||||
_BATCH = 500
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RepairMetadata:
|
||||
source: str | None
|
||||
memory_at: int | None
|
||||
created_at: int | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetadataRepairResult:
|
||||
migration_id: str | None
|
||||
mapped: int
|
||||
vector_rows: int
|
||||
coding_rows: int
|
||||
|
||||
|
||||
def snapshot_data_plane(
|
||||
*,
|
||||
@@ -45,3 +72,301 @@ def restore_data_plane(
|
||||
writers_stopped=writers_stopped,
|
||||
protective_root=protective_root,
|
||||
)
|
||||
|
||||
|
||||
def repair_sdk_metadata_from_bundle(
|
||||
*,
|
||||
data_dir: Path,
|
||||
database_path: Path,
|
||||
now: datetime | None = None,
|
||||
) -> MetadataRepairResult:
|
||||
"""Restore metadata lost by historical cutovers from their verified bundle."""
|
||||
migration_id = _sdk_migration_id(database_path)
|
||||
if migration_id is None:
|
||||
return MetadataRepairResult(None, 0, 0, 0)
|
||||
receipt = data_dir / f".sdk-metadata-repair-v{_METADATA_REPAIR_VERSION}-{migration_id}.json"
|
||||
if receipt.is_file():
|
||||
document = json.loads(receipt.read_text(encoding="utf-8"))
|
||||
return MetadataRepairResult(
|
||||
migration_id,
|
||||
int(document.get("mapped", 0)),
|
||||
int(document.get("vector_rows", 0)),
|
||||
int(document.get("coding_rows", 0)),
|
||||
)
|
||||
legacy = (
|
||||
data_dir
|
||||
/ "backups"
|
||||
/ f"{migration_id}-pre-cutover.sdk-bundle"
|
||||
/ "management"
|
||||
/ "one-memory.sqlite3"
|
||||
)
|
||||
if not legacy.is_file():
|
||||
return MetadataRepairResult(migration_id, 0, 0, 0)
|
||||
mapping = _legacy_metadata(legacy, now=now or datetime.now(UTC))
|
||||
vector_rows = _repair_vector(data_dir, mapping)
|
||||
coding_rows = _repair_coding(data_dir, mapping)
|
||||
result = MetadataRepairResult(
|
||||
migration_id,
|
||||
len(mapping),
|
||||
vector_rows,
|
||||
coding_rows,
|
||||
)
|
||||
temporary = receipt.with_suffix(".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"migration_id": migration_id,
|
||||
"mapped": result.mapped,
|
||||
"vector_rows": vector_rows,
|
||||
"coding_rows": coding_rows,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(receipt)
|
||||
return result
|
||||
|
||||
|
||||
def _sdk_migration_id(database_path: Path) -> str | None:
|
||||
with main_database(database_path).read(row_factory=sqlite3.Row) as database:
|
||||
row = database.execute(
|
||||
"SELECT authority,migration_id FROM memory_authority_state WHERE singleton=1"
|
||||
).fetchone()
|
||||
if row is None or row["authority"] != "sdk":
|
||||
return None
|
||||
value = row["migration_id"]
|
||||
return str(value) if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def _legacy_metadata(path: Path, *, now: datetime) -> dict[str, RepairMetadata]:
|
||||
with closing(sqlite3.connect(f"file:{path}?mode=ro", uri=True)) as database:
|
||||
database.row_factory = sqlite3.Row
|
||||
tables = {
|
||||
str(row[0])
|
||||
for row in database.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
if not {"memory_task_outputs", "governed_memories", "evidence"} <= tables:
|
||||
return {}
|
||||
outputs: list[tuple[str, str, tuple[str, ...]]] = []
|
||||
evidence_ids: set[str] = set()
|
||||
for row in database.execute("SELECT body FROM memory_task_outputs"):
|
||||
try:
|
||||
body = json.loads(str(row["body"]))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
legacy_id = body.get("memory_id")
|
||||
canonical_id = body.get("hy_memory_id")
|
||||
refs = body.get("support_refs")
|
||||
if not isinstance(legacy_id, str) or not isinstance(canonical_id, str):
|
||||
continue
|
||||
identifiers = (
|
||||
tuple(
|
||||
str(ref["evidence_id"])
|
||||
for ref in refs
|
||||
if isinstance(ref, dict) and isinstance(ref.get("evidence_id"), str)
|
||||
)
|
||||
if isinstance(refs, list)
|
||||
else ()
|
||||
)
|
||||
outputs.append((legacy_id, canonical_id, identifiers))
|
||||
evidence_ids.update(identifiers)
|
||||
evidence: dict[str, tuple[str, datetime | None]] = {}
|
||||
for batch in _batches(sorted(evidence_ids)):
|
||||
placeholders = ",".join("?" for _ in batch)
|
||||
for row in database.execute(
|
||||
"SELECT evidence_id,source_id,created_at,imported_at "
|
||||
f"FROM evidence WHERE evidence_id IN ({placeholders})",
|
||||
batch,
|
||||
):
|
||||
evidence[str(row["evidence_id"])] = (
|
||||
str(row["source_id"]),
|
||||
_plausible_time(
|
||||
row["created_at"],
|
||||
fallback=row["imported_at"],
|
||||
now=now,
|
||||
),
|
||||
)
|
||||
governed = {
|
||||
str(row["memory_id"]): _plausible_time(
|
||||
row["created_at"],
|
||||
fallback=row["updated_at"],
|
||||
now=now,
|
||||
)
|
||||
for row in database.execute(
|
||||
"SELECT memory_id,created_at,updated_at FROM governed_memories"
|
||||
)
|
||||
}
|
||||
# Several legacy memories can fold into one SDK memory. Pool their evidence
|
||||
# before deciding, so a disagreeing source cannot be overwritten by whichever
|
||||
# output happened to come last.
|
||||
pooled: dict[str, tuple[set[str], list[datetime], list[datetime]]] = {}
|
||||
for legacy_id, canonical_id, refs in outputs:
|
||||
sources, times, created = pooled.setdefault(canonical_id, (set(), [], []))
|
||||
for identifier in refs:
|
||||
if identifier not in evidence:
|
||||
continue
|
||||
source, timestamp = evidence[identifier]
|
||||
if source and source != "default":
|
||||
sources.add(source)
|
||||
if timestamp is not None:
|
||||
times.append(timestamp)
|
||||
legacy_created = governed.get(legacy_id)
|
||||
if legacy_created is not None:
|
||||
created.append(legacy_created)
|
||||
return {
|
||||
canonical_id: RepairMetadata(
|
||||
source=next(iter(sources)) if len(sources) == 1 else None,
|
||||
memory_at=int(max(times).timestamp()) if times else None,
|
||||
created_at=int(min(created).timestamp()) if created else None,
|
||||
)
|
||||
for canonical_id, (sources, times, created) in pooled.items()
|
||||
}
|
||||
|
||||
|
||||
def _plausible_time(
|
||||
value: object,
|
||||
*,
|
||||
fallback: object,
|
||||
now: datetime,
|
||||
) -> datetime | None:
|
||||
for candidate in (value, fallback):
|
||||
if not isinstance(candidate, str) or not candidate:
|
||||
continue
|
||||
try:
|
||||
parsed = datetime.fromisoformat(candidate)
|
||||
except ValueError:
|
||||
continue
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
parsed = parsed.astimezone(UTC)
|
||||
if parsed <= now + timedelta(days=1):
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
def _repair_vector(
|
||||
data_dir: Path,
|
||||
mapping: dict[str, RepairMetadata],
|
||||
) -> int:
|
||||
directory = data_dir / "hy-memory" / HY_MEMORY_NAMESPACE / "vector"
|
||||
if not mapping or not (directory / "chroma.sqlite3").is_file():
|
||||
return 0
|
||||
import chromadb
|
||||
from chromadb.config import Settings
|
||||
|
||||
client = chromadb.PersistentClient(
|
||||
path=str(directory),
|
||||
settings=Settings(anonymized_telemetry=False),
|
||||
)
|
||||
# The SDK client is not open yet (startup has not created its handle), so
|
||||
# this is the only writer; release the store before the SDK takes it.
|
||||
try:
|
||||
repaired = 0
|
||||
ids = tuple(mapping)
|
||||
for collection in client.list_collections():
|
||||
for batch in _batches(ids):
|
||||
page = collection.get(ids=list(batch), include=["metadatas"])
|
||||
updated_ids: list[str] = []
|
||||
updated_metadata: list[dict[str, object]] = []
|
||||
for memory_id, metadata in zip(
|
||||
page.get("ids") or (),
|
||||
page.get("metadatas") or (),
|
||||
strict=True,
|
||||
):
|
||||
if not isinstance(metadata, dict):
|
||||
continue
|
||||
desired = mapping.get(str(memory_id))
|
||||
if desired is None:
|
||||
continue
|
||||
next_metadata = dict(metadata)
|
||||
changed = False
|
||||
if desired.source and next_metadata.get("source") in {
|
||||
None,
|
||||
"",
|
||||
"default",
|
||||
}:
|
||||
next_metadata["source"] = desired.source
|
||||
changed = True
|
||||
if (
|
||||
desired.memory_at is not None
|
||||
and next_metadata.get("memory_at") != desired.memory_at
|
||||
):
|
||||
next_metadata["memory_at"] = desired.memory_at
|
||||
changed = True
|
||||
if (
|
||||
desired.created_at is not None
|
||||
and next_metadata.get("gmt_created") != desired.created_at
|
||||
):
|
||||
next_metadata["gmt_created"] = desired.created_at
|
||||
changed = True
|
||||
if changed:
|
||||
updated_ids.append(str(memory_id))
|
||||
updated_metadata.append(next_metadata)
|
||||
if updated_ids:
|
||||
collection.update(ids=updated_ids, metadatas=updated_metadata)
|
||||
repaired += len(updated_ids)
|
||||
finally:
|
||||
client.close()
|
||||
return repaired
|
||||
|
||||
|
||||
def _repair_coding(
|
||||
data_dir: Path,
|
||||
mapping: dict[str, RepairMetadata],
|
||||
) -> int:
|
||||
root = data_dir / "hy-memory" / HY_MEMORY_NAMESPACE
|
||||
repaired = 0
|
||||
for name in ("coding.sqlite3", "coding_cv.sqlite3"):
|
||||
path = root / name
|
||||
if not path.is_file():
|
||||
continue
|
||||
with closing(sqlite3.connect(path)) as database:
|
||||
tables = {
|
||||
str(row[0])
|
||||
for row in database.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
for table, identifier in (
|
||||
("coding_memory_meta", "experience_id"),
|
||||
("working_fact", "fact_id"),
|
||||
):
|
||||
if table not in tables:
|
||||
continue
|
||||
rows = database.execute(
|
||||
f"SELECT {identifier},source,created_at FROM {table}"
|
||||
).fetchall()
|
||||
for memory_id, source, created_at in rows:
|
||||
desired = mapping.get(str(memory_id))
|
||||
if desired is None:
|
||||
continue
|
||||
updates: list[str] = []
|
||||
values: list[object] = []
|
||||
if desired.source and source in {None, "", "default"}:
|
||||
updates.append("source=?")
|
||||
values.append(desired.source)
|
||||
if desired.created_at is not None:
|
||||
desired_created_at = (
|
||||
datetime.fromtimestamp(desired.created_at, tz=UTC)
|
||||
.replace(tzinfo=None)
|
||||
.isoformat()
|
||||
)
|
||||
if created_at != desired_created_at:
|
||||
updates.append("created_at=?")
|
||||
values.append(desired_created_at)
|
||||
if not updates:
|
||||
continue
|
||||
values.append(memory_id)
|
||||
database.execute(
|
||||
f"UPDATE {table} SET {','.join(updates)} WHERE {identifier}=?",
|
||||
values,
|
||||
)
|
||||
repaired += 1
|
||||
database.commit()
|
||||
return repaired
|
||||
|
||||
|
||||
def _batches[T](values: list[T] | tuple[T, ...]) -> Iterator[tuple[T, ...]]:
|
||||
for start in range(0, len(values), _BATCH):
|
||||
yield tuple(values[start : start + _BATCH])
|
||||
@@ -95,6 +95,29 @@ class HySdkMemoryPage(BaseModel):
|
||||
|
||||
@classmethod
|
||||
def from_sdk(cls, raw: object, *, limit: int, offset: int) -> HySdkMemoryPage:
|
||||
page, _skipped = cls._parse(raw, limit=limit, offset=offset, skip_invalid=False)
|
||||
return page
|
||||
|
||||
@classmethod
|
||||
def from_sdk_skipping_invalid(
|
||||
cls, raw: object, *, limit: int, offset: int
|
||||
) -> tuple[HySdkMemoryPage, int]:
|
||||
"""Parse a page, leaving out rows outside the public record contract.
|
||||
|
||||
Returns the page and how many rows were left out, so the caller can
|
||||
report them. The rows themselves are never touched.
|
||||
"""
|
||||
return cls._parse(raw, limit=limit, offset=offset, skip_invalid=True)
|
||||
|
||||
@classmethod
|
||||
def _parse(
|
||||
cls,
|
||||
raw: object,
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
skip_invalid: bool,
|
||||
) -> tuple[HySdkMemoryPage, int]:
|
||||
if not isinstance(raw, Mapping):
|
||||
raise HySdkReadError("hy_memory_list_shape_unknown")
|
||||
rows: list[object] = []
|
||||
@@ -115,15 +138,27 @@ class HySdkMemoryPage(BaseModel):
|
||||
total += bucket_total
|
||||
rows.extend(bucket[rows_name])
|
||||
deduplicated: dict[str, HySdkMemoryRecord] = {}
|
||||
skipped = 0
|
||||
for row in rows:
|
||||
record = HySdkMemoryRecord.from_sdk(row)
|
||||
try:
|
||||
record = HySdkMemoryRecord.from_sdk(row)
|
||||
except HySdkReadError:
|
||||
if not skip_invalid:
|
||||
raise
|
||||
skipped += 1
|
||||
continue
|
||||
deduplicated.setdefault(record.memory_id, record)
|
||||
return cls(
|
||||
page = cls(
|
||||
memories=tuple(deduplicated.values()),
|
||||
# `total` is the SDK's stable result-set count. Subtracting only
|
||||
# invalid rows observed on this page would make it change as the
|
||||
# caller paged. Until the SDK exposes a quarantined count, keep the
|
||||
# authoritative total and skip malformed rows only in this page.
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return page, skipped
|
||||
|
||||
|
||||
class HySdkRawRecord(BaseModel):
|
||||
|
||||
@@ -50,6 +50,10 @@ class AuthorizedSourceFile:
|
||||
size: int
|
||||
modified_ns: int
|
||||
changed_ns: int
|
||||
# Source-specific change token (for example Cursor's WAL identity). It is
|
||||
# cursor metadata only; modified_ns remains the real filesystem mtime used
|
||||
# as a fallback conversation timestamp.
|
||||
cursor_version: str | None = None
|
||||
root_identity: str | None = None
|
||||
requested_root: Path | None = None
|
||||
access_check: Callable[[], None] | None = field(default=None, repr=False, compare=False)
|
||||
|
||||
@@ -644,7 +644,7 @@ def is_unchanged(source_file: AuthorizedSourceFile, cursors: dict[str, SourceCur
|
||||
return (
|
||||
previous is not None
|
||||
and previous.read_to_end
|
||||
and previous.file_identity == source_file.file_identity
|
||||
and previous.file_identity == _cursor_file_identity(source_file)
|
||||
and previous.observed_size == source_file.size
|
||||
and previous.observed_modified_ns == source_file.modified_ns
|
||||
# Chmod alone leaves size, mtime and inode untouched, so a file that
|
||||
@@ -691,7 +691,7 @@ def touched_cursor(
|
||||
previous = options.cursors.get(source_file.safe_id)
|
||||
keep = (
|
||||
previous is not None
|
||||
and previous.file_identity == source_file.file_identity
|
||||
and _same_file(previous, source_file)
|
||||
and previous.observed_size <= source_file.size
|
||||
)
|
||||
# A kept cursor means the next scan resumes exactly where this one ended:
|
||||
@@ -871,7 +871,7 @@ def resume_checkpoint(
|
||||
previous = options.cursors.get(source_file.safe_id)
|
||||
if previous is None:
|
||||
return None
|
||||
if previous.file_identity != source_file.file_identity:
|
||||
if not _same_file(previous, source_file):
|
||||
return None
|
||||
if source_file.size < previous.observed_size:
|
||||
return None
|
||||
@@ -1046,7 +1046,7 @@ def source_cursor(
|
||||
) -> SourceCursor:
|
||||
return SourceCursor(
|
||||
file_key=source_file.safe_id,
|
||||
file_identity=source_file.file_identity,
|
||||
file_identity=_cursor_file_identity(source_file),
|
||||
checkpoint=checkpoint,
|
||||
checkpoint_anchor=checkpoint_anchor,
|
||||
observed_size=source_file.size,
|
||||
@@ -1054,6 +1054,27 @@ def source_cursor(
|
||||
)
|
||||
|
||||
|
||||
def _cursor_file_identity(source_file: AuthorizedSourceFile) -> str:
|
||||
return (
|
||||
f"{source_file.file_identity}{_VERSION_SEPARATOR}{source_file.cursor_version}"
|
||||
if source_file.cursor_version
|
||||
else source_file.file_identity
|
||||
)
|
||||
|
||||
|
||||
_VERSION_SEPARATOR = ":v:"
|
||||
|
||||
|
||||
def _same_file(previous: SourceCursor, source_file: AuthorizedSourceFile) -> bool:
|
||||
"""Whether a stored cursor still points into this file.
|
||||
|
||||
The stored identity may carry a change token (Cursor's WAL version). The
|
||||
token says the file changed, which `is_unchanged` handles; it does not mean
|
||||
the file was replaced, so resuming compares the bare `dev:ino` only.
|
||||
"""
|
||||
return previous.file_identity.partition(_VERSION_SEPARATOR)[0] == source_file.file_identity
|
||||
|
||||
|
||||
_POSITION = re.compile(r"(?<=:)\d+(?=:|$)")
|
||||
|
||||
|
||||
|
||||
@@ -533,8 +533,8 @@ def _with_write_ahead_log(database: AuthorizedSourceFile) -> AuthorizedSourceFil
|
||||
version = hashlib.blake2b(
|
||||
f"{database.modified_ns}:{journal.st_mtime_ns}:{journal.st_size}:{journal.st_ino}".encode(),
|
||||
digest_size=8,
|
||||
).digest()
|
||||
return replace(database, modified_ns=int.from_bytes(version, "big") & ((1 << 63) - 1))
|
||||
).hexdigest()
|
||||
return replace(database, cursor_version=version)
|
||||
|
||||
|
||||
def _live_baseline(listing: _CursorListing) -> dict[str, SourceCursor]:
|
||||
|
||||
@@ -22,6 +22,13 @@ class RetiredEvidenceStore:
|
||||
del limit
|
||||
return 0
|
||||
|
||||
def release_sdk_staging(self, evidence_ids: tuple[str, ...]) -> int:
|
||||
# The write drain releases staged Evidence after every SDK write. On
|
||||
# SDK authority nothing is staged; without this, `__getattr__` raised
|
||||
# past `getattr(..., None)` and every completed write logged an error.
|
||||
del evidence_ids
|
||||
return 0
|
||||
|
||||
def get(self, _identifier: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@@ -92,6 +92,15 @@ ALLOWED = {
|
||||
"the memory ids a migrated home must still read back",
|
||||
opened_with="?mode=ro",
|
||||
),
|
||||
("hy_sdk_migration.py", "_legacy_metadata"): Allowed(
|
||||
"the pre-cutover bundle's copy of the legacy database, read-only, to "
|
||||
"recover the source and times historical cutovers dropped",
|
||||
opened_with="?mode=ro",
|
||||
),
|
||||
("hy_sdk_migration.py", "_repair_coding"): Allowed(
|
||||
"the SDK's own Coding/Working stores, not the main database: the one-time "
|
||||
"metadata repair writes them at startup, before the SDK client opens"
|
||||
),
|
||||
("sdk_vector_reattach.py", "_stored_names"): Allowed(
|
||||
"Chroma's own chroma.sqlite3, read-only, to list collection names before "
|
||||
"the offline migration opens Chroma",
|
||||
|
||||
@@ -278,3 +278,155 @@ def test_sdk_user_profile_http_returns_native_assembled_profile(tmp_path):
|
||||
}
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutation(
|
||||
tmp_path,
|
||||
caplog,
|
||||
):
|
||||
async def scenario():
|
||||
app = application(tmp_path)
|
||||
list_calls = []
|
||||
search_calls = []
|
||||
deleted = []
|
||||
records = {
|
||||
"release": {
|
||||
"memory_id": "release",
|
||||
"content": "Release plan",
|
||||
"layer": "l2_fact",
|
||||
"status": "active",
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"session_id": "s1",
|
||||
"source": "cursor",
|
||||
"tags": ["shipping"],
|
||||
},
|
||||
"retired": {
|
||||
"memory_id": "retired",
|
||||
"content": "Release plan, first draft",
|
||||
"layer": "l2_fact",
|
||||
"status": "superseded",
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"session_id": "s1",
|
||||
"source": "cursor",
|
||||
"tags": [],
|
||||
},
|
||||
"default": {
|
||||
"memory_id": "default",
|
||||
"content": "Unknown source",
|
||||
"layer": "l2_fact",
|
||||
"status": "active",
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"session_id": "s3",
|
||||
"source": "default",
|
||||
"tags": [],
|
||||
},
|
||||
}
|
||||
|
||||
class Client:
|
||||
def list_memories(self, **kwargs):
|
||||
list_calls.append(kwargs)
|
||||
rows = [
|
||||
records["release"],
|
||||
records["default"],
|
||||
{
|
||||
"memory_id": "empty",
|
||||
"content": "",
|
||||
"layer": "l2_fact",
|
||||
"status": "active",
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"session_id": "s2",
|
||||
"source": "workbuddy",
|
||||
"tags": [],
|
||||
},
|
||||
]
|
||||
include_sources = kwargs.get("include_sources") or []
|
||||
if include_sources:
|
||||
rows = [row for row in rows if row["source"] in include_sources]
|
||||
return {
|
||||
"vdb": {
|
||||
"memories": rows,
|
||||
"total": len(rows),
|
||||
"limit": kwargs["limit"],
|
||||
"offset": kwargs["offset"],
|
||||
}
|
||||
}
|
||||
|
||||
def search(self, query, **kwargs):
|
||||
search_calls.append((query, kwargs))
|
||||
return {
|
||||
"memories": {
|
||||
"schema": [],
|
||||
"proactive": [],
|
||||
"normal": [
|
||||
{
|
||||
"memory_id": "release",
|
||||
"content": "Release plan",
|
||||
"score": 0.9,
|
||||
},
|
||||
{
|
||||
"memory_id": "retired",
|
||||
"content": "Release plan, first draft",
|
||||
"score": 0.8,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
def get(self, memory_id):
|
||||
return records.get(memory_id)
|
||||
|
||||
def delete(self, memory_id):
|
||||
deleted.append(memory_id)
|
||||
return {"success": True, "deleted_count": 1}
|
||||
|
||||
sdk = Client()
|
||||
|
||||
class Handle:
|
||||
async def client(self):
|
||||
return sdk
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
app.state.sdk_client_handle = Handle()
|
||||
async with client_for(app) as client:
|
||||
response = await client.get(
|
||||
"/api/v2/memory-library",
|
||||
params={"source": "cursor", "query": "release"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
# `total` counts the bounded relevance window; the superseded
|
||||
# hit is in it but is not listed as an active memory.
|
||||
assert body["total"] == 2
|
||||
assert [item["memory_id"] for item in body["memories"]] == ["release"]
|
||||
assert search_calls[0][0] == "release"
|
||||
assert search_calls[0][1]["include_sources"] == ["cursor"]
|
||||
assert search_calls[0][1]["limit"] == 500
|
||||
assert search_calls[0][1]["intention_limit"] == 0
|
||||
assert list_calls == []
|
||||
|
||||
listed = await client.get(
|
||||
"/api/v2/memory-library",
|
||||
params={"source": "workbuddy", "limit": 100},
|
||||
)
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()["memories"] == []
|
||||
assert listed.json()["total"] == 1
|
||||
assert deleted == []
|
||||
assert list_calls[0]["limit"] == 100
|
||||
assert "sdk_memory_invalid_rows_skipped count=1" in caplog.messages
|
||||
|
||||
graph = await client.get("/api/v2/memories/graph")
|
||||
assert graph.status_code == 200
|
||||
node_ids = {node["node_id"] for node in graph.json()["nodes"]}
|
||||
assert "source:cursor" in node_ids
|
||||
assert "source:default" not in node_ids
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -136,6 +136,64 @@ def test_cursor_store_timestamp_envelope_keeps_only_the_user_query_text(tmp_path
|
||||
assert meta["turn_event_at"] == "2026-09-22T23:34:00+00:00"
|
||||
|
||||
|
||||
def test_cursor_wal_change_token_does_not_become_a_future_conversation_time(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
root = tmp_path / ".cursor"
|
||||
database = root / "chats" / "workspace" / "wal-time" / "store.db"
|
||||
database.parent.mkdir(parents=True)
|
||||
connection = sqlite3.connect(database)
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA wal_autocheckpoint=0")
|
||||
connection.execute("CREATE TABLE blobs (id TEXT, data TEXT)")
|
||||
connection.execute(
|
||||
"INSERT INTO blobs VALUES (?, ?)",
|
||||
("message", json.dumps({"role": "assistant", "content": "answer"})),
|
||||
)
|
||||
connection.commit()
|
||||
stamp = 1_788_220_800
|
||||
os.utime(database, (stamp, stamp))
|
||||
try:
|
||||
records = evidence(
|
||||
asyncio.run(scan(CursorUnifiedSourceAdapter(grant=grant("cursor", root))))
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
assert len(records) == 1
|
||||
assert int(records[0].evidence.created_at.timestamp()) == stamp
|
||||
|
||||
|
||||
def test_cursor_wal_store_resumes_from_its_rowid_after_new_frames(tmp_path) -> None:
|
||||
"""The WAL change token marks a store changed; it must not reset its checkpoint."""
|
||||
root = tmp_path / ".cursor"
|
||||
database = root / "chats" / "workspace" / "wal-resume" / "store.db"
|
||||
database.parent.mkdir(parents=True)
|
||||
connection = sqlite3.connect(database)
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA wal_autocheckpoint=0")
|
||||
connection.execute("CREATE TABLE blobs (id TEXT, data TEXT)")
|
||||
connection.execute(
|
||||
"INSERT INTO blobs VALUES (?, ?)",
|
||||
("old", json.dumps({"id": "old", "role": "user", "content": "old row"})),
|
||||
)
|
||||
connection.commit()
|
||||
adapter = CursorUnifiedSourceAdapter(grant=grant("cursor", root))
|
||||
try:
|
||||
baseline = asyncio.run(adapter.live_baseline())
|
||||
assert evidence(asyncio.run(scan(adapter, cursors=baseline))) == []
|
||||
connection.execute(
|
||||
"INSERT INTO blobs VALUES (?, ?)",
|
||||
("new", json.dumps({"id": "new", "role": "user", "content": "new row"})),
|
||||
)
|
||||
connection.commit()
|
||||
appended = asyncio.run(scan(adapter, cursors=baseline))
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
assert [item.evidence.verbatim for item in evidence(appended)] == ["new row"]
|
||||
|
||||
|
||||
def test_cursor_transcript_timestamp_envelope_keeps_only_the_user_query_text(tmp_path) -> None:
|
||||
root = tmp_path / ".cursor"
|
||||
transcript = (
|
||||
|
||||
@@ -4,6 +4,8 @@ import asyncio
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
@@ -15,6 +17,7 @@ from one_memory import cli
|
||||
from one_memory.api import create_app
|
||||
from one_memory.config import Settings
|
||||
from one_memory.database_access import close_main_databases, main_database
|
||||
from one_memory.hy_sdk_migration import repair_sdk_metadata_from_bundle
|
||||
from one_memory.hy_sdk_types import HySdkRawPage
|
||||
from one_memory.imports.storage import EvidenceStore
|
||||
from one_memory.memory_server.contracts import BatchIdentity, WriteRequest
|
||||
@@ -67,6 +70,176 @@ def _start(store, migration_id="m1"):
|
||||
)
|
||||
|
||||
|
||||
def test_post_cutover_metadata_repair_restores_source_and_original_times(store):
|
||||
data_dir = store.database_path.parent
|
||||
migration_id = "sdk-authority-v2-source-repair"
|
||||
with main_database(store.database_path).write() as database, database:
|
||||
database.execute(
|
||||
"UPDATE memory_authority_state SET authority='sdk',migration_id=? WHERE singleton=1",
|
||||
(migration_id,),
|
||||
)
|
||||
legacy = (
|
||||
data_dir
|
||||
/ "backups"
|
||||
/ f"{migration_id}-pre-cutover.sdk-bundle"
|
||||
/ "management"
|
||||
/ "one-memory.sqlite3"
|
||||
)
|
||||
legacy.parent.mkdir(parents=True)
|
||||
with sqlite3.connect(legacy) as database:
|
||||
database.executescript(
|
||||
"""
|
||||
CREATE TABLE memory_task_outputs (body TEXT);
|
||||
CREATE TABLE governed_memories (
|
||||
memory_id TEXT, created_at TEXT, updated_at TEXT
|
||||
);
|
||||
CREATE TABLE evidence (
|
||||
evidence_id TEXT, source_id TEXT, created_at TEXT, imported_at TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
database.execute(
|
||||
"INSERT INTO governed_memories VALUES (?,?,?)",
|
||||
("legacy-1", "2026-10-18T00:00:00+00:00", "2026-10-18T01:00:00+00:00"),
|
||||
)
|
||||
database.execute(
|
||||
"INSERT INTO evidence VALUES (?,?,?,?)",
|
||||
("e1", "workbuddy", "2254-01-01T00:00:00+00:00", "2026-10-17T00:00:00+00:00"),
|
||||
)
|
||||
database.execute(
|
||||
"INSERT INTO memory_task_outputs VALUES (?)",
|
||||
(
|
||||
json.dumps(
|
||||
{
|
||||
"memory_id": "legacy-1",
|
||||
"hy_memory_id": "sdk-1",
|
||||
"support_refs": [{"evidence_id": "e1"}],
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
import chromadb
|
||||
from chromadb.config import Settings as ChromaSettings
|
||||
|
||||
vector = data_dir / "hy-memory" / "governed-v2" / "vector"
|
||||
collection = chromadb.PersistentClient(
|
||||
path=str(vector),
|
||||
settings=ChromaSettings(anonymized_telemetry=False),
|
||||
).get_or_create_collection("memory")
|
||||
collection.add(
|
||||
ids=["sdk-1"],
|
||||
documents=["remember"],
|
||||
embeddings=[[0.1, 0.2]],
|
||||
metadatas=[
|
||||
{
|
||||
"source": "default",
|
||||
"gmt_created": 1,
|
||||
"memory_at": 8_962_051_200,
|
||||
}
|
||||
],
|
||||
)
|
||||
coding = data_dir / "hy-memory" / "governed-v2" / "coding_cv.sqlite3"
|
||||
with sqlite3.connect(coding) as database:
|
||||
database.execute(
|
||||
"CREATE TABLE coding_memory_meta ("
|
||||
"experience_id TEXT,user_id TEXT,agent_id TEXT,solution TEXT,"
|
||||
"source TEXT,created_at TEXT,updated_at TEXT)"
|
||||
)
|
||||
database.execute(
|
||||
"CREATE TABLE working_fact ("
|
||||
"fact_id TEXT,user_id TEXT,agent_id TEXT,statement TEXT,"
|
||||
"source TEXT,created_at TEXT)"
|
||||
)
|
||||
database.execute(
|
||||
"INSERT INTO coding_memory_meta VALUES "
|
||||
"('sdk-1','local','scope','remember','default','now','now')"
|
||||
)
|
||||
|
||||
result = repair_sdk_metadata_from_bundle(
|
||||
data_dir=data_dir,
|
||||
database_path=store.database_path,
|
||||
now=datetime(2026, 10, 25, tzinfo=UTC),
|
||||
)
|
||||
metadata = collection.get(ids=["sdk-1"], include=["metadatas"])["metadatas"][0]
|
||||
assert result.vector_rows == 1
|
||||
assert result.coding_rows == 1
|
||||
assert metadata["source"] == "workbuddy"
|
||||
assert datetime.fromtimestamp(metadata["memory_at"], tz=UTC) == datetime(
|
||||
2026, 10, 17, tzinfo=UTC
|
||||
)
|
||||
assert datetime.fromtimestamp(metadata["gmt_created"], tz=UTC) == datetime(
|
||||
2026, 10, 18, tzinfo=UTC
|
||||
)
|
||||
with sqlite3.connect(coding) as database:
|
||||
source, created_at = database.execute(
|
||||
"SELECT source,created_at FROM coding_memory_meta WHERE experience_id='sdk-1'"
|
||||
).fetchone()
|
||||
assert source == "workbuddy"
|
||||
assert created_at.startswith("2026-10-18T00:00:00")
|
||||
|
||||
repeated = repair_sdk_metadata_from_bundle(
|
||||
data_dir=data_dir,
|
||||
database_path=store.database_path,
|
||||
now=datetime(2026, 10, 25, tzinfo=UTC),
|
||||
)
|
||||
assert repeated == result
|
||||
|
||||
|
||||
def test_metadata_repair_keeps_source_unknown_when_outputs_disagree(tmp_path):
|
||||
"""Two legacy memories folded into one SDK memory pool their evidence."""
|
||||
from one_memory.hy_sdk_migration import _legacy_metadata
|
||||
|
||||
legacy = tmp_path / "one-memory.sqlite3"
|
||||
with closing(sqlite3.connect(legacy)) as database, database:
|
||||
database.executescript(
|
||||
"""
|
||||
CREATE TABLE memory_task_outputs (body TEXT);
|
||||
CREATE TABLE governed_memories (
|
||||
memory_id TEXT, created_at TEXT, updated_at TEXT
|
||||
);
|
||||
CREATE TABLE evidence (
|
||||
evidence_id TEXT, source_id TEXT, created_at TEXT, imported_at TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
database.executemany(
|
||||
"INSERT INTO governed_memories VALUES (?,?,?)",
|
||||
[
|
||||
("legacy-1", "2026-10-18T00:00:00+00:00", None),
|
||||
("legacy-2", "2026-10-12T00:00:00+00:00", None),
|
||||
],
|
||||
)
|
||||
database.executemany(
|
||||
"INSERT INTO evidence VALUES (?,?,?,NULL)",
|
||||
[
|
||||
("e1", "workbuddy", "2026-10-17T00:00:00+00:00"),
|
||||
("e2", "cursor", "2026-10-11T00:00:00+00:00"),
|
||||
],
|
||||
)
|
||||
database.executemany(
|
||||
"INSERT INTO memory_task_outputs VALUES (?)",
|
||||
[
|
||||
(
|
||||
json.dumps(
|
||||
{
|
||||
"memory_id": legacy_id,
|
||||
"hy_memory_id": "sdk-1",
|
||||
"support_refs": [{"evidence_id": evidence_id}],
|
||||
}
|
||||
),
|
||||
)
|
||||
for legacy_id, evidence_id in (("legacy-1", "e1"), ("legacy-2", "e2"))
|
||||
],
|
||||
)
|
||||
|
||||
repaired = _legacy_metadata(legacy, now=datetime(2026, 10, 25, tzinfo=UTC))["sdk-1"]
|
||||
|
||||
assert repaired.source is None
|
||||
assert repaired.memory_at == int(datetime(2026, 10, 17, tzinfo=UTC).timestamp())
|
||||
assert repaired.created_at == int(datetime(2026, 10, 12, tzinfo=UTC).timestamp())
|
||||
|
||||
|
||||
def _insert_evidence(store, evidence_id, *, conversation="session-1", role="user", seq=1):
|
||||
verbatim = f"text of {evidence_id}"
|
||||
with main_database(store.database_path).write() as database, database:
|
||||
@@ -866,3 +1039,124 @@ def test_a_legacy_home_reports_legacy_authority(tmp_path) -> None:
|
||||
|
||||
assert document == {"authority": "legacy", "migration_id": None}
|
||||
assert gate == "legacy"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_the_sdk_memory_map_draws_sdk_memories_without_legacy_tables(tmp_path) -> None:
|
||||
"""Build 21: the memory map had no SDK read and waited forever."""
|
||||
from one_memory.hy_graph_projection import HyGraphEdge, HyGraphNode, HyGraphSnapshot
|
||||
|
||||
rows = [
|
||||
{
|
||||
"memory_id": memory_id,
|
||||
"content": f"memory {memory_id}",
|
||||
"layer": "l2_fact",
|
||||
"status": "active",
|
||||
"is_latest": True,
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"session_id": "s1",
|
||||
"source": "cursor",
|
||||
}
|
||||
for memory_id in ("a", "b")
|
||||
]
|
||||
|
||||
class Client:
|
||||
def list_memories(self, **_kwargs):
|
||||
return {"vdb": {"memories": rows, "total": 7}}
|
||||
|
||||
class Handle:
|
||||
async def client(self):
|
||||
return Client()
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
async def scenario():
|
||||
app = create_app(_app_settings(tmp_path), secret_store=InMemorySecretStore())
|
||||
async with (
|
||||
app.router.lifespan_context(app),
|
||||
AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": "Bearer authority-test"},
|
||||
) as client,
|
||||
):
|
||||
original = app.state.sdk_client_handle
|
||||
app.state.sdk_client_handle = Handle()
|
||||
|
||||
async def graph_snapshot(*, limit):
|
||||
del limit
|
||||
return HyGraphSnapshot(
|
||||
nodes=(HyGraphNode(node_id="t1", content="topic", layer="TOPIC"),),
|
||||
edges=(
|
||||
HyGraphEdge(
|
||||
source_node_id="t1", target_node_id="a", relation="derived_from"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
app.state.sdk_memory_runtime.graph_snapshot = graph_snapshot
|
||||
assert (await client.get("/api/v1/memories/graph")).status_code == 409
|
||||
response = await client.get("/api/v2/memories/graph")
|
||||
app.state.sdk_client_handle = original
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
graph = asyncio.run(scenario())
|
||||
assert (graph["total_memories"], graph["projected_memories"]) == (7, 2)
|
||||
assert {node["node_id"] for node in graph["nodes"]} == {
|
||||
"memory:a",
|
||||
"memory:b",
|
||||
"source:cursor",
|
||||
"hy:t1",
|
||||
}
|
||||
assert {edge["relation"] for edge in graph["edges"]} == {
|
||||
"evidence_source",
|
||||
"same_conversation",
|
||||
"derived_from",
|
||||
}
|
||||
assert graph["hy_graph_available"] is True
|
||||
|
||||
|
||||
def test_a_completed_sdk_write_does_not_touch_retired_evidence(store) -> None:
|
||||
"""Build 21 logged RetiredAuthorityAccess after every successful write."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from one_memory.imports.storage import EvidenceStore as Evidence
|
||||
from one_memory.memory_server.durable_queue import DurableWriteDrain, execute_job
|
||||
from one_memory.retired_authority import RetiredEvidenceStore
|
||||
|
||||
path = store.database_path
|
||||
_insert_evidence(store, "e1")
|
||||
record = Evidence(path).get("e1")
|
||||
assert record is not None
|
||||
queue = DurableWriteQueue(path, sdk_authority=True)
|
||||
job = queue.submit(
|
||||
WriteRequest(
|
||||
internal_session_id="s",
|
||||
source_id="cursor",
|
||||
batch_identity=BatchIdentity(internal_session_id="s", first_round=1, last_round=1),
|
||||
evidence_ids=["e1"],
|
||||
input_records=(record,),
|
||||
idempotency_key="k",
|
||||
)
|
||||
)
|
||||
DurableWriteDrain(queue, execute=lambda _job: asyncio.sleep(0)).claim(limit=1)
|
||||
|
||||
class Supervisor:
|
||||
async def process_sdk_job(self, records, *, request_id, before_hy_invocation):
|
||||
del records, request_id
|
||||
await before_hy_invocation()
|
||||
return SimpleNamespace(raw_ids=("raw-1",), memory_ids=("m-1",))
|
||||
|
||||
asyncio.run(
|
||||
execute_job(
|
||||
job.job_id,
|
||||
queue=queue,
|
||||
supervisor=Supervisor(),
|
||||
evidence=RetiredEvidenceStore(path),
|
||||
)
|
||||
)
|
||||
assert queue.get("k").state == "completed"
|
||||
@@ -112,9 +112,11 @@ import {
|
||||
installBundledCliIfNeeded,
|
||||
installServiceWithDisabledFallback,
|
||||
isInstalledApplicationsApp,
|
||||
requireCurrentRuntimeAtLeast,
|
||||
stableRuntimeErrorCode,
|
||||
stableRuntimeFailureDetail,
|
||||
startStableRuntimeRepair,
|
||||
startupFailureCause,
|
||||
} from "./stable-runtime";
|
||||
import { STARTUP_SCREEN_URL, startupScreenHtml } from "./startup-screen";
|
||||
import {
|
||||
@@ -1093,7 +1095,7 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
component: "desktop.sdk_migration",
|
||||
event: "sdk_migration.startup",
|
||||
outcome:
|
||||
migration.state === "cutover"
|
||||
migration.state === "cutover" || migration.state === "complete"
|
||||
? "succeeded"
|
||||
: migration.state === "deferred"
|
||||
? "blocked"
|
||||
@@ -1158,30 +1160,52 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
const previousOwner = owner;
|
||||
let runtimeChanged = false;
|
||||
let transientRuntime = false;
|
||||
if (owner?.owner !== "cli") {
|
||||
const installed = await installPackagedRuntime({
|
||||
resourcesPath: process.resourcesPath,
|
||||
executablePath: process.execPath,
|
||||
installRoot,
|
||||
platform: "darwin",
|
||||
arch: process.arch,
|
||||
...(packagedBuildNumber === undefined
|
||||
? {}
|
||||
: { buildNumber: packagedBuildNumber }),
|
||||
...(packagedCommit === undefined
|
||||
? {}
|
||||
: { gitCommit: packagedCommit }),
|
||||
// Only the packaged Desktop path opts into a valid same-build,
|
||||
// different-commit takeover. CLI installs remain strict.
|
||||
replaceSameBuild: true,
|
||||
serviceEnableMarker: runtimeServiceEnableMarkerPath(dataDir),
|
||||
});
|
||||
runtimeChanged = !installed.reused_current;
|
||||
await installBundledCliIfNeeded({
|
||||
home: app.getPath("home"),
|
||||
installRoot,
|
||||
platform: "darwin",
|
||||
});
|
||||
// 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
|
||||
// on the previous Runtime and, with `current` gone, on none at all.
|
||||
{
|
||||
let installed: Awaited<
|
||||
ReturnType<typeof installPackagedRuntime>
|
||||
> | null = null;
|
||||
try {
|
||||
installed = await installPackagedRuntime({
|
||||
resourcesPath: process.resourcesPath,
|
||||
executablePath: process.execPath,
|
||||
installRoot,
|
||||
platform: "darwin",
|
||||
arch: process.arch,
|
||||
...(packagedBuildNumber === undefined
|
||||
? {}
|
||||
: { buildNumber: packagedBuildNumber }),
|
||||
...(packagedCommit === undefined
|
||||
? {}
|
||||
: { gitCommit: packagedCommit }),
|
||||
// Only the packaged Desktop path opts into a valid same-build,
|
||||
// different-commit takeover. CLI installs remain strict.
|
||||
replaceSameBuild: true,
|
||||
serviceEnableMarker: runtimeServiceEnableMarkerPath(dataDir),
|
||||
});
|
||||
} catch (error) {
|
||||
// `memhub upgrade` may already have installed a newer build.
|
||||
if (
|
||||
!(
|
||||
owner?.owner === "cli" &&
|
||||
error instanceof Error &&
|
||||
error.message === "distribution_downgrade_refused"
|
||||
)
|
||||
)
|
||||
throw error;
|
||||
}
|
||||
if (installed !== null) {
|
||||
runtimeChanged = !installed.reused_current;
|
||||
await installBundledCliIfNeeded({
|
||||
home: app.getPath("home"),
|
||||
installRoot,
|
||||
platform: "darwin",
|
||||
});
|
||||
}
|
||||
await requireCurrentRuntimeAtLeast(installRoot, packagedBuildNumber);
|
||||
}
|
||||
if (
|
||||
!desktopBackgroundLaunch &&
|
||||
@@ -1485,6 +1509,9 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
outcome: "failed",
|
||||
error_code: stableRuntimeErrorCode(error),
|
||||
...diagnosticError(error),
|
||||
// The code alone is often `desktop_start_failed`; the cause is what
|
||||
// support needs. Home is shortened and the text bounded.
|
||||
details: { cause: startupFailureCause(error, app.getPath("home")) },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -15,10 +15,12 @@ import {
|
||||
installServiceWithDisabledFallback,
|
||||
isDisabledServiceInstallError,
|
||||
isInstalledApplicationsApp,
|
||||
requireCurrentRuntimeAtLeast,
|
||||
stableRuntimeErrorCode,
|
||||
stableRuntimeFailureDetail,
|
||||
stableRuntimeUserMessage,
|
||||
startStableRuntimeRepair,
|
||||
startupFailureCause,
|
||||
} from "./stable-runtime";
|
||||
|
||||
it("only treats a packaged macOS executable under Applications as installed", () => {
|
||||
@@ -311,4 +313,53 @@ describe("startup dialog body", () => {
|
||||
expect(detail).toContain("磁盘空间不足");
|
||||
expect(detail).not.toContain("sdk_migration_disk_space_insufficient");
|
||||
});
|
||||
|
||||
it("turns runtime_service_not_ready into a user-facing sentence", () => {
|
||||
const error = new Error("runtime_service_not_ready");
|
||||
expect(stableRuntimeErrorCode(error)).toBe("runtime_service_not_ready");
|
||||
expect(stableRuntimeFailureDetail(error)).toContain("首次启动");
|
||||
expect(stableRuntimeFailureDetail(error)).not.toContain(
|
||||
"runtime_service_not_ready",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("packaged runtime build after an App upgrade", () => {
|
||||
it("refuses to run on a Runtime older than the App, or none", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "memhub-current-"));
|
||||
await expect(requireCurrentRuntimeAtLeast(root, 21)).rejects.toThrow(
|
||||
"packaged_runtime_not_current",
|
||||
);
|
||||
await mkdir(path.join(root, "current"));
|
||||
const manifest = path.join(root, "current", "manifest.json");
|
||||
await writeFile(manifest, JSON.stringify({ build_number: 19 }));
|
||||
await expect(requireCurrentRuntimeAtLeast(root, 21)).rejects.toThrow(
|
||||
"packaged_runtime_not_current",
|
||||
);
|
||||
await writeFile(manifest, JSON.stringify({ build_number: 21 }));
|
||||
await expect(
|
||||
requireCurrentRuntimeAtLeast(root, 21),
|
||||
).resolves.toBeUndefined();
|
||||
await writeFile(manifest, JSON.stringify({ build_number: 22 }));
|
||||
await expect(
|
||||
requireCurrentRuntimeAtLeast(root, 21),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
requireCurrentRuntimeAtLeast(root, undefined),
|
||||
).resolves.toBeUndefined();
|
||||
expect(
|
||||
stableRuntimeUserMessage(new Error("packaged_runtime_not_current")),
|
||||
).toContain("重新安装");
|
||||
});
|
||||
|
||||
it("keeps the cause of a startup failure, without the home path", () => {
|
||||
const cause = startupFailureCause(
|
||||
new Error("ENOENT: no such file /Users/someone/Library/x\nmore"),
|
||||
"/Users/someone",
|
||||
);
|
||||
expect(cause).toBe("Error: ENOENT: no such file ~/Library/x more");
|
||||
expect(startupFailureCause(new Error("x".repeat(500)), "/h").length).toBe(
|
||||
300,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -129,6 +129,7 @@ const SAFE_STABLE_RUNTIME_ERROR_CODES = new Set([
|
||||
// Migration runs before the app window exists, so these are the only names
|
||||
// a first launch can be given. They stay codes here and become sentences in
|
||||
// STABLE_RUNTIME_USER_MESSAGES: a person reading the dialog never sees them.
|
||||
"packaged_runtime_not_current",
|
||||
"sdk_migration_preflight_failed",
|
||||
"sdk_migration_memory_unreadable",
|
||||
"sdk_migration_model_profile_required",
|
||||
@@ -140,6 +141,7 @@ const SAFE_STABLE_RUNTIME_ERROR_CODES = new Set([
|
||||
"sdk_migration_response_invalid",
|
||||
"sdk_migration_status_unavailable",
|
||||
"sdk_migration_incomplete",
|
||||
"runtime_service_not_ready",
|
||||
]);
|
||||
|
||||
export function stableRuntimeErrorCode(error: unknown): string {
|
||||
@@ -205,6 +207,8 @@ export const STABLE_RUNTIME_USER_MESSAGES: Record<string, string> = {
|
||||
"已安装的运行环境比这个安装包更新,无法用旧安装包启动。请安装最新版本的 MemHub;你的记忆数据不会受到影响。",
|
||||
install_current_unmanaged:
|
||||
"运行环境的安装目录被改动过,无法安全接管。请重新安装 MemHub,你的记忆数据不会受到影响。",
|
||||
packaged_runtime_not_current:
|
||||
"新版本的运行环境没有装上,MemHub 不会用旧版本继续运行。请重新安装 MemHub;如果仍然失败,请把这个提示反馈给我们。你的记忆数据不会受到影响。",
|
||||
sdk_migration_memory_unreadable:
|
||||
"升级前核对发现,新版本还读不到你的全部记忆,所以没有切换数据源,旧数据保持原样可用。请把这个提示反馈给我们;原有记忆数据不会丢失。",
|
||||
sdk_migration_model_profile_required:
|
||||
@@ -227,6 +231,8 @@ export const STABLE_RUNTIME_USER_MESSAGES: Record<string, string> = {
|
||||
"升级过程返回了无法识别的内容,已停止。原有记忆数据不会丢失;请重新启动重试,如果仍然失败,请把这个提示反馈给我们。",
|
||||
sdk_migration_incomplete:
|
||||
"记忆数据格式升级没有完成,MemHub 暂时无法打开。请重新启动重试;如果仍然失败,请把这个提示反馈给我们,原有记忆数据不会丢失。",
|
||||
runtime_service_not_ready:
|
||||
"记忆服务仍在首次启动,请稍候后重试。你的记忆数据不会受到影响。",
|
||||
desktop_start_failed:
|
||||
"MemHub 启动失败。请重新启动;如果仍然打不开,请重新安装,你的记忆数据不会受到影响。",
|
||||
};
|
||||
@@ -252,3 +258,31 @@ export function stableRuntimeFailureDetail(error: unknown): string {
|
||||
}
|
||||
return STABLE_RUNTIME_USER_MESSAGES[error.message] ?? error.message;
|
||||
}
|
||||
|
||||
/** The installed Runtime must be at least the build this App carries. */
|
||||
export async function requireCurrentRuntimeAtLeast(
|
||||
installRoot: string,
|
||||
buildNumber: number | undefined,
|
||||
): Promise<void> {
|
||||
if (buildNumber === undefined) return;
|
||||
const manifest = await readFile(
|
||||
path.join(installRoot, "current", "manifest.json"),
|
||||
"utf8",
|
||||
)
|
||||
.then((text) => JSON.parse(text) as { build_number?: unknown })
|
||||
.catch(() => null);
|
||||
const installed = manifest?.build_number;
|
||||
if (typeof installed !== "number" || installed < buildNumber) {
|
||||
throw new Error("packaged_runtime_not_current");
|
||||
}
|
||||
}
|
||||
|
||||
export function startupFailureCause(error: unknown, home: string): string {
|
||||
const text =
|
||||
error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
return text
|
||||
.split(home)
|
||||
.join("~")
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.slice(0, 300);
|
||||
}
|
||||
@@ -177,7 +177,7 @@ it("OBS-22: the graph inspector has no editing entry and never says changes cann
|
||||
await act(async () => {});
|
||||
// The detail pane is open and the page is being re-read, which used to be
|
||||
// the exact condition that printed the warning.
|
||||
expect(screen.getByText("共同原文")).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
|
||||
expect(screen.queryByText(/暂不能保存更改/)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -224,11 +224,11 @@ it("OBS-15: switching memory shows a read in progress instead of the previous me
|
||||
// screen while it is open.
|
||||
render(<Host simplified deferMemoryId="m1" />);
|
||||
const list = screen.getByRole("list", { name: "地图记忆列表" });
|
||||
expect(screen.getByText("共同原文")).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
|
||||
await user.click(within(list).getByRole("button", { name: /第二条/ }));
|
||||
// The first memory's evidence must not still be presented as if it
|
||||
// belonged to the selection that is now the second one.
|
||||
expect(screen.queryByText("共同原文")).toBeNull();
|
||||
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
|
||||
expect(screen.getByRole("status").textContent).toContain("正在读取这条记忆");
|
||||
});
|
||||
|
||||
@@ -247,16 +247,26 @@ it("labels the right-hand empty state as memory details", () => {
|
||||
expect(screen.queryByText("原始证据")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders SDK-projected memory tags with the existing Workbench chips", () => {
|
||||
const { container } = render(<Host simplified />);
|
||||
expect(
|
||||
Array.from(
|
||||
container.querySelectorAll(".map-memory-tags .wb-tag"),
|
||||
(node) => node.textContent,
|
||||
),
|
||||
).toEqual(["产品", "产品"]);
|
||||
});
|
||||
|
||||
it("opens the newest listed memory in the detail pane and keeps it closed after dismiss", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Host simplified />);
|
||||
const list = screen.getByRole("list", { name: "地图记忆列表" });
|
||||
const first = within(list).getByRole("button", { name: /第一条/ });
|
||||
expect(first.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(screen.getByText("共同原文")).toBeTruthy();
|
||||
expect(screen.getByText(/原对话/)).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
|
||||
expect(screen.queryByText(/原对话/)).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "关闭记忆详情" }));
|
||||
expect(screen.queryByText("共同原文")).toBeNull();
|
||||
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
|
||||
});
|
||||
|
||||
it("OBS-14: lists what the conversation settled instead of asking to settle first", async () => {
|
||||
|
||||
@@ -988,7 +988,16 @@ export function MemoryMap(props: Props) {
|
||||
onClick={() => pick(m.memory_id)}
|
||||
>
|
||||
<span className={`map-state ${m.status}`} aria-hidden="true" />
|
||||
<span>{m.content}</span>
|
||||
<span className="map-memory-content">{m.content}</span>
|
||||
{m.tags.length ? (
|
||||
<span className="map-memory-tags wb-tags">
|
||||
{m.tags.map((tag) => (
|
||||
<span key={tag} className="wb-tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
<small>
|
||||
{props.simplified ? (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// @vitest-environment jsdom
|
||||
import type {
|
||||
GovernedMemory,
|
||||
MemoryGraphResponse,
|
||||
SdkMemoryRecord,
|
||||
} from "@one-memory/contracts";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { MemorySpace } from "./MemorySpace";
|
||||
import { sdkMapMemory } from "./sdk-memory-map";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
authority: vi.fn(),
|
||||
legacyLibrary: vi.fn(),
|
||||
legacyGraph: vi.fn(),
|
||||
sdkLibrary: vi.fn(),
|
||||
sdkGraph: vi.fn(),
|
||||
sdkMemory: vi.fn(),
|
||||
}));
|
||||
vi.mock("./api", () => ({
|
||||
getMemoryAuthority: api.authority,
|
||||
getMemoryLibrary: api.legacyLibrary,
|
||||
getMemories: async () => [],
|
||||
getMemoryGraph: api.legacyGraph,
|
||||
getSdkMemoryLibrary: api.sdkLibrary,
|
||||
getSdkMemoryGraph: api.sdkGraph,
|
||||
getSdkMemory: api.sdkMemory,
|
||||
updateSdkMemory: vi.fn(),
|
||||
deleteSdkMemory: vi.fn(),
|
||||
getMemoryProcessing: async () => null,
|
||||
getModelProfiles: async () => [],
|
||||
apiErrorCode: () => null,
|
||||
}));
|
||||
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
|
||||
vi.mock("./MemoryNoteComposer", () => ({ MemoryNoteComposer: () => null }));
|
||||
vi.mock("./MemoryMap", () => ({
|
||||
MemoryMap: ({
|
||||
hidden,
|
||||
memories,
|
||||
graph,
|
||||
loaded,
|
||||
onOpenMemory,
|
||||
}: {
|
||||
hidden: boolean;
|
||||
memories: GovernedMemory[];
|
||||
graph: MemoryGraphResponse | null;
|
||||
loaded: boolean;
|
||||
onOpenMemory(id: string): void;
|
||||
}) => (
|
||||
<div hidden={hidden} data-testid="map">
|
||||
<span data-testid="map-memories">
|
||||
{memories.map((m) => `${m.memory_id}:${m.status}`).join(",")}
|
||||
</span>
|
||||
<span data-testid="map-nodes">{graph?.nodes.length ?? "none"}</span>
|
||||
<span data-testid="map-loaded">{String(loaded)}</span>
|
||||
<button type="button" onClick={() => onOpenMemory("a")}>
|
||||
open a
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./SdkMemoryLibrary", () => ({
|
||||
SdkMemoryLibrary: ({
|
||||
focusedMemoryId,
|
||||
}: {
|
||||
focusedMemoryId: string | null;
|
||||
}) => <div data-testid="sdk-library">{focusedMemoryId ?? ""}</div>,
|
||||
}));
|
||||
vi.mock("./MemoryRecallScope", () => ({
|
||||
MemoryRecallScope: () => null,
|
||||
memoryProvenanceLabel: () => "原话",
|
||||
}));
|
||||
|
||||
const record = (memory_id: string): SdkMemoryRecord => ({
|
||||
memory_id,
|
||||
content: `memory ${memory_id}`,
|
||||
layer: "l2_fact",
|
||||
status: "active",
|
||||
is_latest: true,
|
||||
supersedes: [],
|
||||
superseded_by: [],
|
||||
tags: ["工作"],
|
||||
owner: null,
|
||||
source_raw_ids: [],
|
||||
user_id: "local",
|
||||
agent_id: "all-apps",
|
||||
session_id: "s1",
|
||||
source: "cursor",
|
||||
memory_at: null,
|
||||
gmt_created: 1_761_000_000,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function sdkAuthority() {
|
||||
api.authority.mockResolvedValue({ authority: "sdk", migration_id: "m" });
|
||||
api.sdkLibrary.mockResolvedValue({
|
||||
memories: [record("a"), record("b")],
|
||||
total: 3018,
|
||||
limit: 300,
|
||||
offset: 0,
|
||||
});
|
||||
api.sdkGraph.mockResolvedValue({
|
||||
total_memories: 3018,
|
||||
projected_memories: 2,
|
||||
nodes: [
|
||||
{ node_id: "memory:a", node_type: "memory", label: "a", memory_id: "a" },
|
||||
{ node_id: "memory:b", node_type: "memory", label: "b", memory_id: "b" },
|
||||
{ node_id: "source:cursor", node_type: "source", label: "Cursor" },
|
||||
],
|
||||
edges: [],
|
||||
hy_graph_available: true,
|
||||
});
|
||||
}
|
||||
|
||||
it("draws the map from SDK memories on SDK authority, without legacy reads", async () => {
|
||||
sdkAuthority();
|
||||
|
||||
render(<MemorySpace simplified active />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-loaded").textContent).toBe("true"),
|
||||
);
|
||||
expect(screen.getByTestId("map").hidden).toBe(false);
|
||||
expect(screen.getByTestId("map-memories").textContent).toBe(
|
||||
"a:confirmed,b:confirmed",
|
||||
);
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("3");
|
||||
expect(await screen.findByText(/3,018/)).toBeTruthy();
|
||||
expect(api.sdkLibrary).toHaveBeenCalledWith(
|
||||
{ statuses: ["active"], offset: 0, limit: 300 },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(api.legacyLibrary).not.toHaveBeenCalled();
|
||||
expect(api.legacyGraph).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens an SDK memory in place instead of switching away from the map", async () => {
|
||||
sdkAuthority();
|
||||
api.sdkMemory.mockResolvedValue(record("a"));
|
||||
|
||||
render(<MemorySpace simplified active />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-loaded").textContent).toBe("true"),
|
||||
);
|
||||
fireEvent.click(screen.getByText("open a"));
|
||||
|
||||
await waitFor(() => expect(api.sdkMemory).toHaveBeenCalledWith("a"));
|
||||
// The inspector stays on the map; the reader is never moved to a second list.
|
||||
expect(screen.getByTestId("map").hidden).toBe(false);
|
||||
expect(screen.getByTestId("sdk-library").textContent).toBe("");
|
||||
});
|
||||
|
||||
it("keeps asking for the authority until Core answers", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
sdkAuthority();
|
||||
api.authority
|
||||
.mockRejectedValueOnce(new Error("core_not_ready"))
|
||||
.mockRejectedValueOnce(new Error("core_not_ready"));
|
||||
|
||||
render(<MemorySpace simplified active />);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
|
||||
await waitFor(() => expect(api.authority).toHaveBeenCalledTimes(3));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-loaded").textContent).toBe("true"),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("projects SDK records into the fields the map groups by", () => {
|
||||
const coding = sdkMapMemory({ ...record("c"), layer: "coding_experience" });
|
||||
expect(coding).toMatchObject({
|
||||
memory_id: "c",
|
||||
kind: "coding",
|
||||
status: "confirmed",
|
||||
tags: ["工作"],
|
||||
deleted_at: null,
|
||||
});
|
||||
expect(sdkMapMemory({ ...record("s"), layer: "l6_schema" }).kind).toBe(
|
||||
"schema",
|
||||
);
|
||||
expect(sdkMapMemory({ ...record("x"), status: "superseded" }).status).toBe(
|
||||
"superseded",
|
||||
);
|
||||
});
|
||||
|
||||
const node = (
|
||||
node_id: string,
|
||||
node_type: MemoryGraphResponse["nodes"][number]["node_type"],
|
||||
extra: Partial<MemoryGraphResponse["nodes"][number]> = {},
|
||||
): MemoryGraphResponse["nodes"][number] => ({
|
||||
node_id,
|
||||
node_type,
|
||||
label: node_id,
|
||||
memory_id: null,
|
||||
source_id: null,
|
||||
kind: null,
|
||||
status: null,
|
||||
confidence: null,
|
||||
...extra,
|
||||
});
|
||||
const provenance = (
|
||||
from: string,
|
||||
to: string,
|
||||
): MemoryGraphResponse["edges"][number] => ({
|
||||
edge_id: `${from}->${to}`,
|
||||
source_node_id: from,
|
||||
target_node_id: to,
|
||||
relation: "evidence_source",
|
||||
weight: null,
|
||||
origin: "provenance",
|
||||
});
|
||||
|
||||
it("groups and draws SDK memories by source with the real map model", async () => {
|
||||
const { mapGroups, projectMap } = await import("./memory-map-model");
|
||||
const memories = [record("a"), record("b")].map(sdkMapMemory);
|
||||
const graph: MemoryGraphResponse = {
|
||||
total_memories: 2,
|
||||
projected_memories: 2,
|
||||
nodes: [
|
||||
node("memory:a", "memory", { memory_id: "a" }),
|
||||
node("memory:b", "memory", { memory_id: "b" }),
|
||||
node("source:cursor", "source", { source_id: "cursor", label: "Cursor" }),
|
||||
node("hy:t1", "topic"),
|
||||
],
|
||||
edges: [
|
||||
provenance("source:cursor", "memory:a"),
|
||||
provenance("source:cursor", "memory:b"),
|
||||
],
|
||||
hy_graph_available: true,
|
||||
};
|
||||
|
||||
const groups = mapGroups(memories, graph, "source");
|
||||
expect(groups.map((group) => [group.key, group.memoryIds])).toEqual([
|
||||
["source:cursor", ["a", "b"]],
|
||||
]);
|
||||
const drawn = projectMap(graph, memories, "source", ["a", "b"]);
|
||||
expect(
|
||||
drawn.nodes
|
||||
.filter((node) => node.node_type === "memory")
|
||||
.map((n) => n.memory_id),
|
||||
).toEqual(["a", "b"]);
|
||||
expect(drawn.edges.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
ImportJob,
|
||||
MemoryDetail,
|
||||
MemoryGraphResponse,
|
||||
MemoryLibraryPage,
|
||||
MemoryProcessingSummary,
|
||||
SdkAskMemory,
|
||||
SourceResponse,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
apiErrorCode,
|
||||
askMemoriesStream,
|
||||
correctMemory,
|
||||
deleteSdkMemory,
|
||||
digestMemoryGraph,
|
||||
getMemories,
|
||||
getMemory,
|
||||
@@ -39,12 +41,16 @@ import {
|
||||
getMemoryGraph,
|
||||
getMemoryProcessing,
|
||||
getModelProfiles,
|
||||
getSdkMemory,
|
||||
getSdkMemoryGraph,
|
||||
getSdkMemoryLibrary,
|
||||
manualExtract,
|
||||
mutateMemory,
|
||||
retryUnknownMemoryProcessing,
|
||||
saveMemoryNote,
|
||||
setExtractionWindow,
|
||||
syncMemoryIndex,
|
||||
updateSdkMemory,
|
||||
} from "./api";
|
||||
import { ConversationSearch } from "./ConversationSearch";
|
||||
import { clockStamp } from "./clock-stamp";
|
||||
@@ -56,6 +62,7 @@ import { subscribePoll } from "./poll-scheduler";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import { SdkMemoryLibrary } from "./SdkMemoryLibrary";
|
||||
import { SdkRawHistory } from "./SdkRawHistory";
|
||||
import { sdkDetail, sdkLibraryPage, sdkMapMemory } from "./sdk-memory-map";
|
||||
import { sourceNameOr } from "./source-label";
|
||||
import { traceUiAction } from "./ui-trace";
|
||||
import { useMemoryLibrary } from "./use-memory-library";
|
||||
@@ -168,13 +175,23 @@ export function MemorySpace({
|
||||
if ((!active && !preload) || typeof getMemoryAuthority !== "function")
|
||||
return;
|
||||
let current = true;
|
||||
void getMemoryAuthority()
|
||||
.then((result) => {
|
||||
if (current) setMemoryAuthority(result.authority);
|
||||
})
|
||||
.catch(() => {});
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
// The app opens before Core is ready, so the first ask usually fails.
|
||||
// Without a retry the authority stayed unknown and nothing ever loaded.
|
||||
const ask = (delay: number) => {
|
||||
void getMemoryAuthority()
|
||||
.then((result) => {
|
||||
if (current) setMemoryAuthority(result.authority);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!current) return;
|
||||
timer = setTimeout(() => ask(Math.min(delay * 2, 10_000)), delay);
|
||||
});
|
||||
};
|
||||
ask(500);
|
||||
return () => {
|
||||
current = false;
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
};
|
||||
}, [active, preload]);
|
||||
useEffect(() => {
|
||||
@@ -283,9 +300,117 @@ export function MemorySpace({
|
||||
text: string;
|
||||
memories: AskMemory[];
|
||||
} | null>(null);
|
||||
const [sdkFocusedMemoryId, setSdkFocusedMemoryId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
// The SDK library is no longer where a memory opens from the map; the id is
|
||||
// kept so the panel still has a focus target for its own tests.
|
||||
const [sdkFocusedMemoryId] = useState<string | null>(null);
|
||||
// The map on SDK authority. Legacy reads (library page, governed graph)
|
||||
// are refused there, so it draws SDK memories and the v2 graph instead.
|
||||
const [sdkMap, setSdkMap] = useState<{
|
||||
memories: GovernedMemory[];
|
||||
graph: MemoryGraphResponse;
|
||||
} | null>(null);
|
||||
const [sdkMapFailed, setSdkMapFailed] = useState(false);
|
||||
// A write on SDK authority has no governed refresh to ride on; bumping this
|
||||
// re-reads the SDK page and graph after an edit or a delete.
|
||||
const [sdkMapRevision, setSdkMapRevision] = useState(0);
|
||||
const bumpSdkMap = useCallback(() => setSdkMapRevision((v) => v + 1), []);
|
||||
// The map's left list pages through the SDK library the same way it pages
|
||||
// through the governed one: the list is always one page, and the count above
|
||||
// it is the library's total, not the page's length.
|
||||
const [sdkLibrary, setSdkLibrary] = useState<{
|
||||
page: MemoryLibraryPage | null;
|
||||
loading: boolean;
|
||||
failed: boolean;
|
||||
}>({ page: null, loading: false, failed: false });
|
||||
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
|
||||
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
|
||||
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
|
||||
const sdkMapVisible =
|
||||
memoryAuthority === "sdk" &&
|
||||
simplified &&
|
||||
active &&
|
||||
displayedMode === "map";
|
||||
useEffect(() => {
|
||||
if (!sdkMapVisible) return;
|
||||
// A write bumps the revision; reading it here re-runs the read at once
|
||||
// instead of waiting for the 30s poll.
|
||||
void sdkMapRevision;
|
||||
let cancelled = false;
|
||||
let controller: AbortController | null = null;
|
||||
const read = async () => {
|
||||
controller?.abort();
|
||||
const request = new AbortController();
|
||||
controller = request;
|
||||
try {
|
||||
const [page, graph] = await Promise.all([
|
||||
getSdkMemoryLibrary(
|
||||
{ statuses: ["active"], offset: 0, limit: 300 },
|
||||
request.signal,
|
||||
),
|
||||
getSdkMemoryGraph(request.signal),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setSdkMap({ memories: page.memories.map(sdkMapMemory), graph });
|
||||
setSdkMapFailed(false);
|
||||
} catch {
|
||||
if (!cancelled && !request.signal.aborted) setSdkMapFailed(true);
|
||||
}
|
||||
};
|
||||
void read();
|
||||
const timer = setInterval(() => void read(), 30_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
controller?.abort();
|
||||
};
|
||||
}, [sdkMapVisible, sdkMapRevision]);
|
||||
useEffect(() => {
|
||||
if (memoryAuthority !== "sdk" || !simplified || !active) return;
|
||||
if (displayedMode !== "map") return;
|
||||
void sdkMapRevision;
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
setSdkLibrary((current) => ({ ...current, loading: true }));
|
||||
void getSdkMemoryLibrary(
|
||||
{
|
||||
statuses: ["active"],
|
||||
source: sdkLibrarySource || undefined,
|
||||
query: sdkLibraryQuery || undefined,
|
||||
offset: sdkLibraryOffset,
|
||||
limit: 100,
|
||||
},
|
||||
controller.signal,
|
||||
)
|
||||
.then((page) => {
|
||||
if (cancelled) return;
|
||||
setSdkLibrary({
|
||||
page: sdkLibraryPage(page),
|
||||
loading: false,
|
||||
failed: false,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled || controller.signal.aborted) return;
|
||||
setSdkLibrary((current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
failed: true,
|
||||
}));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [
|
||||
memoryAuthority,
|
||||
simplified,
|
||||
active,
|
||||
displayedMode,
|
||||
sdkLibraryOffset,
|
||||
sdkLibraryQuery,
|
||||
sdkLibrarySource,
|
||||
sdkMapRevision,
|
||||
]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [selection, setSelection] = useState<Set<string>>(new Set());
|
||||
const bulkInFlight = useRef(false);
|
||||
@@ -969,8 +1094,9 @@ export function MemorySpace({
|
||||
open(memory.memory_id);
|
||||
return;
|
||||
}
|
||||
setSdkFocusedMemoryId(memory.memory_id);
|
||||
setMode("library");
|
||||
// An SDK answer cites SDK memories; they open in the inspector like any
|
||||
// other rather than switching the reader to a second list.
|
||||
void open(memory.memory_id);
|
||||
}
|
||||
|
||||
async function saveAnswer(draft: { content: string }) {
|
||||
@@ -1019,7 +1145,13 @@ export function MemorySpace({
|
||||
setDetailFreshness(null);
|
||||
setDetailLoad({ memoryId, status: "loading" });
|
||||
try {
|
||||
const result = await getMemory(memoryId);
|
||||
// Governed detail is refused on SDK authority, so the inspector reads the
|
||||
// same memory through v2 and renders it in place. Staying here keeps the
|
||||
// map's list, graph and detail where the design put them.
|
||||
const result =
|
||||
memoryAuthority === "sdk"
|
||||
? sdkDetail(await getSdkMemory(memoryId))
|
||||
: await getMemory(memoryId);
|
||||
if (epoch !== detailEpoch.current || result.memory.memory_id !== memoryId)
|
||||
return;
|
||||
setDetail(result);
|
||||
@@ -1067,6 +1199,28 @@ export function MemorySpace({
|
||||
return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
if (memoryAuthority === "sdk") {
|
||||
// An SDK memory is removed outright: there is no revision to undo.
|
||||
if (actionName !== "delete") {
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
const trace = traceUiAction("memory", "confirm");
|
||||
try {
|
||||
await deleteSdkMemory(detail.memory.memory_id);
|
||||
trace.done("succeeded");
|
||||
closeDetail();
|
||||
bumpSdkMap();
|
||||
} catch (reason) {
|
||||
trace.done(writeTraceStatus(reason), {
|
||||
reason: "memory_action_failed",
|
||||
});
|
||||
setError(message(reason, t));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const intent = `${detail.memory.memory_id}:${actionName}:${detail.memory.revision}`;
|
||||
const requestId = requestIdFor(requestIds.current, intent);
|
||||
const trace = traceUiAction("memory", "confirm");
|
||||
@@ -1273,6 +1427,28 @@ export function MemorySpace({
|
||||
return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
if (memoryAuthority === "sdk") {
|
||||
// A rewrite replaces the SDK row instead of adding a revision, so there
|
||||
// is no revision to send and nothing to reconcile afterwards.
|
||||
const trace = traceUiAction("memory", "submit");
|
||||
try {
|
||||
const saved = await updateSdkMemory(detail.memory.memory_id, draft);
|
||||
trace.done("succeeded");
|
||||
setDetail(sdkDetail(saved));
|
||||
setDraft(saved.content);
|
||||
bumpSdkMap();
|
||||
} catch (reason) {
|
||||
trace.done(writeTraceStatus(reason), {
|
||||
reason: isDefiniteRefusal(reason)
|
||||
? "memory_correct_failed"
|
||||
: "memory_correct_result_unknown",
|
||||
});
|
||||
setError(message(reason, t));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const intent = `${detail.memory.memory_id}:correct:${detail.memory.revision}`;
|
||||
const requestId = requestIdFor(requestIds.current, intent);
|
||||
const trace = traceUiAction("memory", "submit");
|
||||
@@ -1444,33 +1620,51 @@ export function MemorySpace({
|
||||
{simplified ? t("memory:记忆") : t("memory:记忆空间")}
|
||||
</h1>
|
||||
<span className="memory-loaded-count">
|
||||
{simplified && !management
|
||||
? mapLibrary.failed
|
||||
{memoryAuthority === "sdk" && simplified && !management
|
||||
? sdkMapFailed && !sdkMap
|
||||
? t("memory:记忆读取失败")
|
||||
: mapLibrary.stale
|
||||
? t("memory:正在导入历史会话,记忆将陆续出现")
|
||||
: mapLibrary.page
|
||||
: sdkMap
|
||||
? tn(
|
||||
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
|
||||
sdkMap.graph.total_memories,
|
||||
{
|
||||
filter: "",
|
||||
shown: new Intl.NumberFormat(locale).format(
|
||||
sdkLibrary.page?.items.length ??
|
||||
sdkMap.memories.length,
|
||||
),
|
||||
},
|
||||
)
|
||||
: t("memory:正在读取…")
|
||||
: memoryAuthority === "sdk"
|
||||
? null
|
||||
: simplified && !management
|
||||
? mapLibrary.failed
|
||||
? t("memory:记忆读取失败")
|
||||
: mapLibrary.stale
|
||||
? t("memory:正在导入历史会话,记忆将陆续出现")
|
||||
: mapLibrary.page
|
||||
? tn(
|
||||
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
|
||||
mapLibrary.page.total,
|
||||
{
|
||||
filter: settledFilterLabel
|
||||
? t("memory:({filter})", {
|
||||
filter: settledFilterLabel,
|
||||
})
|
||||
: "",
|
||||
shown: new Intl.NumberFormat(locale).format(
|
||||
mapLibrary.page.items.length,
|
||||
),
|
||||
},
|
||||
)
|
||||
: t("memory:正在读取…")
|
||||
: loaded
|
||||
? tn(
|
||||
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
|
||||
mapLibrary.page.total,
|
||||
{
|
||||
filter: settledFilterLabel
|
||||
? t("memory:({filter})", {
|
||||
filter: settledFilterLabel,
|
||||
})
|
||||
: "",
|
||||
shown: new Intl.NumberFormat(locale).format(
|
||||
mapLibrary.page.items.length,
|
||||
),
|
||||
},
|
||||
"memory:已加载 {count} 条记忆",
|
||||
memories.filter((m) => m.status !== "deleted").length,
|
||||
)
|
||||
: t("memory:正在读取…")
|
||||
: loaded
|
||||
? tn(
|
||||
"memory:已加载 {count} 条记忆",
|
||||
memories.filter((m) => m.status !== "deleted").length,
|
||||
)
|
||||
: t("memory:正在读取…")}
|
||||
: t("memory:正在读取…")}
|
||||
</span>
|
||||
</div>
|
||||
<MemoryNoteComposer
|
||||
@@ -1814,23 +2008,37 @@ export function MemorySpace({
|
||||
focusDialogue={focusDialogue}
|
||||
onFocusDialogueConsumed={onFocusDialogueConsumed}
|
||||
libraryView={
|
||||
simplified
|
||||
simplified && memoryAuthority === "sdk"
|
||||
? {
|
||||
query: mapQuery,
|
||||
source: mapSource,
|
||||
page: mapLibrary.page,
|
||||
loading: mapLibrary.loading,
|
||||
failed: mapLibrary.failed,
|
||||
stale: sourceSnapshotStale,
|
||||
onQuery: setMapQuery,
|
||||
onSource: setMapSource,
|
||||
resetPage: () => mapLibrary.go(0),
|
||||
onPage: mapLibrary.go,
|
||||
onRetry: () => {
|
||||
void mapLibrary.refresh().catch(() => {});
|
||||
},
|
||||
query: sdkLibraryQuery,
|
||||
source: sdkLibrarySource,
|
||||
page: sdkLibrary.page,
|
||||
loading: sdkLibrary.loading,
|
||||
failed: sdkLibrary.failed,
|
||||
stale: false,
|
||||
onQuery: setSdkLibraryQuery,
|
||||
onSource: setSdkLibrarySource,
|
||||
resetPage: () => setSdkLibraryOffset(0),
|
||||
onPage: (offset) => setSdkLibraryOffset(offset),
|
||||
onRetry: bumpSdkMap,
|
||||
}
|
||||
: undefined
|
||||
: simplified
|
||||
? {
|
||||
query: mapQuery,
|
||||
source: mapSource,
|
||||
page: mapLibrary.page,
|
||||
loading: mapLibrary.loading,
|
||||
failed: mapLibrary.failed,
|
||||
stale: sourceSnapshotStale,
|
||||
onQuery: setMapQuery,
|
||||
onSource: setMapSource,
|
||||
resetPage: () => mapLibrary.go(0),
|
||||
onPage: mapLibrary.go,
|
||||
onRetry: () => {
|
||||
void mapLibrary.refresh().catch(() => {});
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMutationComplete={(id) => {
|
||||
void refresh(query)
|
||||
@@ -1838,19 +2046,39 @@ export function MemorySpace({
|
||||
.catch((reason) => setError(message(reason, t)));
|
||||
}}
|
||||
hidden={!active || displayedMode !== "map"}
|
||||
memories={simplified ? (mapLibrary.page?.items ?? []) : memories}
|
||||
graph={simplified && sourceSnapshotStale ? null : graph}
|
||||
memories={
|
||||
memoryAuthority === "sdk"
|
||||
? (sdkMap?.memories ?? [])
|
||||
: simplified
|
||||
? (mapLibrary.page?.items ?? [])
|
||||
: memories
|
||||
}
|
||||
graph={
|
||||
memoryAuthority === "sdk"
|
||||
? (sdkMap?.graph ?? null)
|
||||
: simplified && sourceSnapshotStale
|
||||
? null
|
||||
: graph
|
||||
}
|
||||
sources={sources}
|
||||
processing={processing}
|
||||
arrival={memoryArrival}
|
||||
onViewHistory={simplified ? onConnect : () => setMode("history")}
|
||||
loaded={simplified ? mapLibrary.page !== null : loaded}
|
||||
loaded={
|
||||
memoryAuthority === "sdk"
|
||||
? sdkMap !== null
|
||||
: simplified
|
||||
? mapLibrary.page !== null
|
||||
: loaded
|
||||
}
|
||||
detail={detail}
|
||||
detailLoad={detailLoad}
|
||||
overlayBlocked={
|
||||
!active ||
|
||||
deleteCount > 0 ||
|
||||
(simplified && (!mapLibrary.page || sourceSnapshotStale))
|
||||
(memoryAuthority === "sdk"
|
||||
? sdkMap === null
|
||||
: simplified && (!mapLibrary.page || sourceSnapshotStale))
|
||||
}
|
||||
draft={draft}
|
||||
busy={busy || detailReadBlocked}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { MemoryDetail } from "@one-memory/contracts";
|
||||
import type { MemoryDetail, SourceResponse } from "@one-memory/contracts";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
@@ -14,7 +14,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
|
||||
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
|
||||
|
||||
it("localizes informational details and dates while preserving evidence and memory", async () => {
|
||||
it("localizes memory details without exposing the retired evidence surface", async () => {
|
||||
delete window.oneMemory;
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
|
||||
render(
|
||||
@@ -29,9 +29,9 @@ it("localizes informational details and dates while preserving evidence and memo
|
||||
);
|
||||
expect(await screen.findByText("Guardada · Versión 2")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Evidencia original" }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("这次先确认范围。")).toBeTruthy();
|
||||
screen.queryByRole("heading", { name: "Evidencia original" }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByText("这次先确认范围。")).toBeNull();
|
||||
expect(correct).not.toHaveBeenCalled();
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
localStorage.clear();
|
||||
@@ -145,6 +145,72 @@ it.each([
|
||||
expect(screen.getByText(`${label} · 第 2 版`)).toBeTruthy();
|
||||
expect(screen.queryByText("待核对 · 第 2 版")).toBeNull();
|
||||
});
|
||||
it("shows the native SDK state without inventing a revision", () => {
|
||||
render(
|
||||
<SelectedMemoryDetail
|
||||
detail={{
|
||||
...detail,
|
||||
memory: { ...detail.memory, revision: 0 },
|
||||
evidence: [],
|
||||
sdk_status: "shadow",
|
||||
source_id: "cursor",
|
||||
sdk_memory_at: "2026-09-18T00:00:00Z",
|
||||
sdk_created_at: "2026-09-19T00:00:00Z",
|
||||
}}
|
||||
sources={[
|
||||
{ source_id: "cursor", display_name: "Cursor" } as SourceResponse,
|
||||
]}
|
||||
onChanged={vi.fn()}
|
||||
hideActions
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("已隐藏")).toBeTruthy();
|
||||
expect(screen.queryByText(/第 0 版/)).toBeNull();
|
||||
expect(screen.getByText(/^来源 Cursor · /)).toBeTruthy();
|
||||
});
|
||||
it("splits long memory text into a bounded heading and readable body", () => {
|
||||
const content = "长".repeat(100);
|
||||
render(
|
||||
<SelectedMemoryDetail
|
||||
detail={{
|
||||
...detail,
|
||||
memory: { ...detail.memory, content },
|
||||
evidence: [],
|
||||
sdk_status: "active",
|
||||
}}
|
||||
sources={[]}
|
||||
onChanged={vi.fn()}
|
||||
hideActions
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("heading", { name: "长".repeat(72) })).toBeTruthy();
|
||||
expect(screen.getByText("长".repeat(28)).className).toContain(
|
||||
"selected-memory-body",
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
[
|
||||
"a late sentence end",
|
||||
`${"长".repeat(80)}。${"尾".repeat(10)}`,
|
||||
"长".repeat(72),
|
||||
],
|
||||
["words", `${"word ".repeat(20)}end`, "word ".repeat(14).trim()],
|
||||
])("keeps the heading within the limit for %s", (_case, content, heading) => {
|
||||
render(
|
||||
<SelectedMemoryDetail
|
||||
detail={{
|
||||
...detail,
|
||||
memory: { ...detail.memory, content },
|
||||
evidence: [],
|
||||
sdk_status: "active",
|
||||
}}
|
||||
sources={[]}
|
||||
onChanged={vi.fn()}
|
||||
hideActions
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("heading", { name: heading })).toBeTruthy();
|
||||
});
|
||||
it("opens full editor and saves content without rewriting scope", async () => {
|
||||
const changed = vi.fn();
|
||||
render(
|
||||
@@ -204,27 +270,7 @@ it("keeps draft after conflict until latest revision is explicitly acknowledged"
|
||||
),
|
||||
);
|
||||
});
|
||||
it("original page never substitutes unavailable evidence with stale text", () => {
|
||||
const view = render(
|
||||
<SelectedMemoryDetail detail={detail} sources={[]} onChanged={() => {}} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "查看原文" }));
|
||||
expect(screen.getAllByText("这次先确认范围。").length).toBeGreaterThan(0);
|
||||
view.rerender(
|
||||
<SelectedMemoryDetail
|
||||
detail={{
|
||||
...detail,
|
||||
evidence: [{ ...detail.evidence[0], available: false }],
|
||||
}}
|
||||
sources={[]}
|
||||
onChanged={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("这次先确认范围。")).toBeNull();
|
||||
expect(screen.getByText("来源权限已变化或原文不可用。")).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each(["编辑", "查看原文", "删除记忆"])(
|
||||
it.each(["编辑", "删除记忆"])(
|
||||
"returns focus to %s without scrolling the background",
|
||||
(name) => {
|
||||
render(
|
||||
@@ -275,7 +321,6 @@ it.each(["escape", "cancel"])(
|
||||
"value",
|
||||
"尚未保存的草稿",
|
||||
);
|
||||
expect(screen.getByText("这次先确认范围。")).toBeTruthy();
|
||||
expect(correct).not.toHaveBeenCalled();
|
||||
confirm.mockReturnValue(true);
|
||||
close();
|
||||
@@ -535,7 +580,7 @@ it("suspends the editor without losing its draft or leaving the background inert
|
||||
);
|
||||
});
|
||||
|
||||
it("stamps today's source time the same way as 06A and 06E", () => {
|
||||
it("stamps today's source and conversation time without an evidence page", () => {
|
||||
render(
|
||||
<SelectedMemoryDetail
|
||||
detail={{
|
||||
@@ -552,8 +597,6 @@ it("stamps today's source time the same way as 06A and 06E", () => {
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/来源 workbuddy · 今天 /)).toBeTruthy();
|
||||
expect(screen.getByText(/原对话 今天 /)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "查看原文" }));
|
||||
expect(screen.getByRole("heading", { name: "原始对话" })).toBeTruthy();
|
||||
expect(screen.getByText(/^workbuddy · 今天 /)).toBeTruthy();
|
||||
expect(screen.getByText(/对话时间 今天 /)).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "查看原文" })).toBeNull();
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { MemoryDetail, SourceResponse } from "@one-memory/contracts";
|
||||
import type {
|
||||
MemoryDetail,
|
||||
SdkMemoryStatus,
|
||||
SourceResponse,
|
||||
} from "@one-memory/contracts";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { apiErrorCode, correctMemory, getMemory, mutateMemory } from "./api";
|
||||
@@ -10,7 +14,9 @@ import { traceUiAction } from "./ui-trace";
|
||||
import { isDefiniteRefusal, writeTraceStatus } from "./write-outcome";
|
||||
import "./selected-memory.css";
|
||||
|
||||
type Page = "read" | "edit" | "original" | "delete";
|
||||
type Page = "read" | "edit" | "delete";
|
||||
|
||||
const HEADING_LIMIT = 72;
|
||||
|
||||
export function SelectedMemoryDetail({
|
||||
detail,
|
||||
@@ -50,8 +56,17 @@ export function SelectedMemoryDetail({
|
||||
}, []);
|
||||
const memory = detail.memory;
|
||||
const evidence = detail.evidence[0];
|
||||
const statusLabel =
|
||||
memory.status === "confirmed"
|
||||
const sdkStatusLabels: Record<SdkMemoryStatus, Parameters<typeof t>[0]> = {
|
||||
active: "selectedMemory.status.active",
|
||||
superseded: "selectedMemory.status.superseded",
|
||||
shadow: "selectedMemory.status.shadow",
|
||||
negated: "selectedMemory.status.negated",
|
||||
conflicted: "selectedMemory.status.conflicted",
|
||||
archived: "selectedMemory.status.archived",
|
||||
};
|
||||
const statusLabel = detail.sdk_status
|
||||
? t(sdkStatusLabels[detail.sdk_status])
|
||||
: memory.status === "confirmed"
|
||||
? t("selectedMemory.status.confirmed")
|
||||
: memory.status === "deleted"
|
||||
? t("selectedMemory.status.deleted")
|
||||
@@ -60,7 +75,7 @@ export function SelectedMemoryDetail({
|
||||
: t("selectedMemory.status.unknown");
|
||||
const source = sourceNameOr(
|
||||
sources,
|
||||
evidence?.source_id,
|
||||
detail.source_id ?? evidence?.source_id,
|
||||
t("selectedMemory.sourceUnknown"),
|
||||
);
|
||||
const dirty = page === "edit" && draft !== memory.content;
|
||||
@@ -221,20 +236,22 @@ export function SelectedMemoryDetail({
|
||||
if (alive.current) setBusy(false);
|
||||
}
|
||||
}
|
||||
const author =
|
||||
evidence?.written_by === "person" || evidence?.written_by === "user"
|
||||
? t("selectedMemory.author.you")
|
||||
: evidence?.written_by === "agent" || evidence?.written_by === "assistant"
|
||||
? t("selectedMemory.author.assistant")
|
||||
: t("selectedMemory.author.unknown");
|
||||
const timestamp = clockStamp(evidence?.created_at, locale);
|
||||
const split = memory.content.search(/[。\n]/);
|
||||
// The heading is the first sentence when it fits in HEADING_LIMIT
|
||||
// characters; otherwise the text breaks at the last space within the limit
|
||||
// (CJK text has none, so it breaks at the limit itself).
|
||||
const punctuation = memory.content.search(/[。!?!?\n]/);
|
||||
const space = memory.content.lastIndexOf(" ", HEADING_LIMIT);
|
||||
const split =
|
||||
punctuation > 0 && punctuation < HEADING_LIMIT
|
||||
? punctuation + 1
|
||||
: memory.content.length > HEADING_LIMIT
|
||||
? space > HEADING_LIMIT / 2
|
||||
? space
|
||||
: HEADING_LIMIT
|
||||
: -1;
|
||||
const heading =
|
||||
split > 0 && split < 85
|
||||
? memory.content.slice(0, split + 1)
|
||||
: memory.content;
|
||||
const body =
|
||||
split > 0 && split < 85 ? memory.content.slice(split + 1).trim() : "";
|
||||
split > 0 ? memory.content.slice(0, split).trim() : memory.content;
|
||||
const body = split > 0 ? memory.content.slice(split).trim() : "";
|
||||
return (
|
||||
<>
|
||||
<article
|
||||
@@ -242,23 +259,37 @@ export function SelectedMemoryDetail({
|
||||
data-hide-actions={hideActions ? "true" : "false"}
|
||||
>
|
||||
<p className="selected-memory-status">
|
||||
{statusLabel} ·{" "}
|
||||
{t("selectedMemory.version", {
|
||||
revision: new Intl.NumberFormat(locale).format(memory.revision),
|
||||
})}
|
||||
{statusLabel}
|
||||
{!detail.sdk_status &&
|
||||
` · ${t("selectedMemory.version", {
|
||||
revision: new Intl.NumberFormat(locale).format(memory.revision),
|
||||
})}`}
|
||||
</p>
|
||||
<h3>{heading}</h3>
|
||||
{body && <p className="selected-memory-body">{body}</p>}
|
||||
<p className="selected-memory-source">
|
||||
{t("selectedMemory.sourceLine", { source, time: timestamp })}
|
||||
</p>
|
||||
{detail.source_id || detail.evidence.length ? (
|
||||
<p className="selected-memory-source">
|
||||
{t("selectedMemory.sourceLine", {
|
||||
source,
|
||||
time: clockStamp(
|
||||
detail.sdk_memory_at ?? evidence?.created_at,
|
||||
locale,
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="selected-memory-times">
|
||||
{t("selectedMemory.timestamps", {
|
||||
conversation: clockStamp(
|
||||
conversationAt?.at ?? evidence?.created_at,
|
||||
conversationAt?.at ??
|
||||
detail.sdk_memory_at ??
|
||||
evidence?.created_at,
|
||||
locale,
|
||||
),
|
||||
created: clockStamp(
|
||||
detail.sdk_created_at ?? memory.updated_at,
|
||||
locale,
|
||||
),
|
||||
created: clockStamp(memory.updated_at, locale),
|
||||
})}
|
||||
</p>
|
||||
{!hideActions && (
|
||||
@@ -276,27 +307,6 @@ export function SelectedMemoryDetail({
|
||||
{t("selectedMemory.edit")}
|
||||
</R6Button>
|
||||
)}
|
||||
<section className="selected-memory-evidence">
|
||||
<h4>{t("selectedMemory.evidence")}</h4>
|
||||
<small>
|
||||
{t("selectedMemory.evidenceLine", { author, time: timestamp })}
|
||||
</small>
|
||||
<blockquote>
|
||||
{evidence?.available
|
||||
? evidence.verbatim
|
||||
: t("selectedMemory.evidenceUnavailable")}
|
||||
</blockquote>
|
||||
{!hideActions && (
|
||||
<R6Button
|
||||
onClick={(event) => {
|
||||
trigger.current = event.currentTarget;
|
||||
setPage("original");
|
||||
}}
|
||||
>
|
||||
{t("selectedMemory.viewOriginal")}
|
||||
</R6Button>
|
||||
)}
|
||||
</section>
|
||||
{!hideActions && (
|
||||
<R6Button variant="quiet" onClick={() => setShowHistory((v) => !v)}>
|
||||
{t("selectedMemory.history")}
|
||||
@@ -345,9 +355,7 @@ export function SelectedMemoryDetail({
|
||||
aria-label={
|
||||
page === "edit"
|
||||
? t("selectedMemory.editTitle")
|
||||
: page === "original"
|
||||
? t("selectedMemory.originalTitle")
|
||||
: t("selectedMemory.delete")
|
||||
: t("selectedMemory.delete")
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Tab") {
|
||||
@@ -380,13 +388,8 @@ export function SelectedMemoryDetail({
|
||||
<h1 ref={focus} tabIndex={-1}>
|
||||
{page === "edit"
|
||||
? t("selectedMemory.editTitle")
|
||||
: page === "original"
|
||||
? t("selectedMemory.originalTitle")
|
||||
: t("selectedMemory.delete")}
|
||||
: t("selectedMemory.delete")}
|
||||
</h1>
|
||||
{page === "original" && (
|
||||
<R6Button onClick={close}>{t("selectedMemory.back")}</R6Button>
|
||||
)}
|
||||
</header>
|
||||
{page === "edit" ? (
|
||||
<div className="selected-editor">
|
||||
@@ -443,40 +446,6 @@ export function SelectedMemoryDetail({
|
||||
</R6Button>
|
||||
</div>
|
||||
</div>
|
||||
) : page === "original" ? (
|
||||
<div className="selected-original">
|
||||
<small>
|
||||
{source} · {timestamp}
|
||||
</small>
|
||||
<p>{author}</p>
|
||||
{detail.evidence.map((item) => (
|
||||
<section key={item.evidence_id}>
|
||||
<small>
|
||||
{sourceNameOr(
|
||||
sources,
|
||||
item.source_id,
|
||||
t("selectedMemory.sourceUnknown"),
|
||||
)}{" "}
|
||||
·{" "}
|
||||
{item.written_by === "person"
|
||||
? t("selectedMemory.author.user")
|
||||
: item.written_by === "agent"
|
||||
? t("selectedMemory.author.agent")
|
||||
: t("selectedMemory.author.unknown")}
|
||||
</small>
|
||||
<blockquote>
|
||||
{item.available
|
||||
? item.verbatim
|
||||
: t("selectedMemory.sourceUnavailable")}
|
||||
</blockquote>
|
||||
</section>
|
||||
))}
|
||||
<small>{t("selectedMemory.related")}</small>
|
||||
<h2>{heading}</h2>
|
||||
<R6Button onClick={close}>
|
||||
{t("selectedMemory.viewMemory")}
|
||||
</R6Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="selected-delete">
|
||||
<h2>{t("selectedMemory.deleteTitle")}</h2>
|
||||
|
||||
@@ -68,6 +68,8 @@ describe("paged memory library client", () => {
|
||||
expect(
|
||||
await getSdkMemoryLibrary({
|
||||
statuses: ["active", "superseded"],
|
||||
source: "cursor",
|
||||
query: "release",
|
||||
offset: 0,
|
||||
limit: 25,
|
||||
}),
|
||||
@@ -76,5 +78,7 @@ describe("paged memory library client", () => {
|
||||
const params = get.mock.calls[0][1].params as URLSearchParams;
|
||||
expect(params.getAll("statuses")).toEqual(["active", "superseded"]);
|
||||
expect(params.get("limit")).toBe("25");
|
||||
expect(params.get("source")).toBe("cursor");
|
||||
expect(params.get("query")).toBe("release");
|
||||
});
|
||||
});
|
||||
@@ -891,10 +891,14 @@ export async function getMemoryLibrary(
|
||||
export async function getSdkMemoryLibrary(
|
||||
{
|
||||
statuses = ["active"],
|
||||
source,
|
||||
query,
|
||||
offset = 0,
|
||||
limit = 100,
|
||||
}: {
|
||||
statuses?: SdkMemoryStatus[];
|
||||
source?: string;
|
||||
query?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
} = {},
|
||||
@@ -905,6 +909,8 @@ export async function getSdkMemoryLibrary(
|
||||
limit: String(limit),
|
||||
});
|
||||
for (const status of statuses) params.append("statuses", status);
|
||||
if (source) params.set("source", source);
|
||||
if (query?.trim()) params.set("query", query.trim());
|
||||
const response = await client.get<SdkMemoryPage>("/api/v2/memory-library", {
|
||||
params,
|
||||
signal,
|
||||
@@ -1162,6 +1168,17 @@ export async function getMemory(memoryId: string): Promise<MemoryDetail> {
|
||||
*/
|
||||
let pendingMemoryGraph: Promise<MemoryGraphResponse> | null = null;
|
||||
|
||||
/** The memory map on SDK authority: SDK memories plus the graph overlay. */
|
||||
export async function getSdkMemoryGraph(
|
||||
signal?: AbortSignal,
|
||||
): Promise<MemoryGraphResponse> {
|
||||
const response = await client.get<MemoryGraphResponse>(
|
||||
"/api/v2/memories/graph",
|
||||
{ params: { limit: 300 }, signal },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getMemoryGraphWith(
|
||||
transport: { get: typeof client.get } = client,
|
||||
): Promise<MemoryGraphResponse> {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import type {
|
||||
GovernedMemory,
|
||||
MemoryDetail,
|
||||
MemoryKind,
|
||||
MemoryLibraryPage,
|
||||
SdkMemoryPage,
|
||||
SdkMemoryRecord,
|
||||
} from "@one-memory/contracts";
|
||||
|
||||
function kindOf(layer: string): MemoryKind {
|
||||
if (layer === "l6_schema") return "schema";
|
||||
if (layer === "l7_intention") return "intention";
|
||||
if (layer.startsWith("coding")) return "coding";
|
||||
if (layer.startsWith("working")) return "working";
|
||||
return "chat";
|
||||
}
|
||||
|
||||
function stamp(seconds: number | null | undefined): string {
|
||||
return typeof seconds === "number" && Number.isFinite(seconds)
|
||||
? new Date(seconds * 1_000).toISOString()
|
||||
: "";
|
||||
}
|
||||
|
||||
/** An SDK memory in the shape the memory map draws.
|
||||
*
|
||||
* The map groups and labels by id, content, kind, status and tags only, so the
|
||||
* governed-only fields are inert placeholders here. Opening, editing and
|
||||
* deleting still go through the SDK (v2) endpoints — `sdkDetail()` below wraps
|
||||
* the same projection for the map's inspector, which is why the inspector can
|
||||
* stay where the design put it instead of sending the reader to another view.
|
||||
*/
|
||||
export function sdkMapMemory(record: SdkMemoryRecord): GovernedMemory {
|
||||
const created = stamp(record.gmt_created ?? record.memory_at);
|
||||
return {
|
||||
memory_id: record.memory_id,
|
||||
user_id: record.user_id,
|
||||
kind: kindOf(record.layer),
|
||||
status:
|
||||
record.status === "active"
|
||||
? "confirmed"
|
||||
: record.status === "superseded"
|
||||
? "superseded"
|
||||
: "deleted",
|
||||
content: record.content,
|
||||
scopes: [],
|
||||
evidence_ids: [],
|
||||
origin: "extracted",
|
||||
tags: [...record.tags],
|
||||
supersedes: [...record.supersedes],
|
||||
superseded_by: [...record.superseded_by],
|
||||
// GovernedMemory still requires a revision for the shared map model. SDK
|
||||
// has no revision contract; the inspector recognises sdk_status and never
|
||||
// renders this placeholder.
|
||||
revision: 0,
|
||||
created_at: created,
|
||||
updated_at: created,
|
||||
deleted_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** One SDK memory as the map's inspector reads it.
|
||||
*
|
||||
* The inspector was built for a governed detail. SDK-native memory has no
|
||||
* product revision or "original evidence" section; source_raw_ids remain an
|
||||
* internal SDK provenance relation and are deliberately not projected as UI
|
||||
* evidence.
|
||||
*/
|
||||
export function sdkDetail(record: SdkMemoryRecord): MemoryDetail {
|
||||
return {
|
||||
memory: sdkMapMemory(record),
|
||||
evidence: [],
|
||||
history: [],
|
||||
index_sync: null,
|
||||
sdk_status: record.status,
|
||||
source_id:
|
||||
record.source && record.source !== "default" ? record.source : null,
|
||||
sdk_memory_at: stamp(record.memory_at) || null,
|
||||
sdk_created_at: stamp(record.gmt_created) || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** One SDK page as the map's left list reads it.
|
||||
*
|
||||
* The list asks a library page for its rows, its total and — per row — when
|
||||
* the cited conversation happened. The SDK page carries the row itself, so the
|
||||
* time is taken from the memory: `memory_at` is the moment the conversation
|
||||
* happened, `gmt_created` only the moment it was imported. Naming which one it
|
||||
* is keeps "对话于" from silently becoming "沉淀于".
|
||||
*/
|
||||
export function sdkLibraryPage(page: SdkMemoryPage): MemoryLibraryPage {
|
||||
const sourceTime: Record<
|
||||
string,
|
||||
{ at: string; basis: "conversation" | "import" }
|
||||
> = {};
|
||||
const sourceIds: Record<string, string[]> = {};
|
||||
for (const record of page.memories) {
|
||||
if (record.source && record.source !== "default")
|
||||
sourceIds[record.memory_id] = [record.source];
|
||||
const at = record.memory_at ?? record.gmt_created;
|
||||
if (typeof at !== "number" || !Number.isFinite(at)) continue;
|
||||
sourceTime[record.memory_id] = {
|
||||
at: new Date(at * 1_000).toISOString(),
|
||||
basis: record.memory_at ? "conversation" : "import",
|
||||
};
|
||||
}
|
||||
return {
|
||||
items: page.memories.map(sdkMapMemory),
|
||||
total: page.total,
|
||||
offset: page.offset,
|
||||
limit: page.limit,
|
||||
source_ids_by_memory: sourceIds,
|
||||
source_time_by_memory: sourceTime,
|
||||
};
|
||||
}
|
||||
@@ -138,13 +138,19 @@
|
||||
gap: 8px;
|
||||
font-family: "Noto Sans SC", "PingFang SC", sans-serif;
|
||||
}
|
||||
.r6-shell .r6-memory .map-memory-item > span:not(.map-state) {
|
||||
.r6-shell .r6-memory .map-memory-item > .map-memory-content {
|
||||
min-height: 40px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.r6-shell .r6-memory .map-memory-tags.wb-tags {
|
||||
grid-column: 2;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.r6-shell .r6-memory .map-memory-item small {
|
||||
font-size: 11px;
|
||||
line-height: 19px;
|
||||
@@ -212,15 +218,19 @@
|
||||
margin: 0 0 32px;
|
||||
}
|
||||
.selected-memory-detail > h3 {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
line-height: 38px;
|
||||
font-weight: 500;
|
||||
letter-spacing: normal;
|
||||
margin: 0 0 18px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.selected-memory-body {
|
||||
display: block;
|
||||
font-size: 17px;
|
||||
line-height: 29px;
|
||||
letter-spacing: normal;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -237,36 +247,6 @@
|
||||
color: #5e6e7a;
|
||||
line-height: 21px;
|
||||
}
|
||||
.selected-memory-evidence {
|
||||
border-top: 1px solid #c7d6e0;
|
||||
margin: 24px 0 16px;
|
||||
padding-top: 22px;
|
||||
}
|
||||
.selected-memory-evidence h4 {
|
||||
font-size: 16px;
|
||||
line-height: 28px;
|
||||
margin: 0 0 18px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.selected-memory-evidence small {
|
||||
font-size: 12px;
|
||||
color: #5e6e7a;
|
||||
}
|
||||
.selected-memory-evidence blockquote {
|
||||
font-size: 15px;
|
||||
line-height: 26px;
|
||||
margin: 18px 0 44px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.selected-memory-detail[data-hide-actions="true"]
|
||||
.selected-memory-evidence
|
||||
blockquote {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.selected-memory-more {
|
||||
font-size: 12px;
|
||||
padding: 16px 0;
|
||||
@@ -304,8 +284,7 @@
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
.selected-memory-page > header .r6-button,
|
||||
.selected-original > .r6-button {
|
||||
.selected-memory-page > header .r6-button {
|
||||
min-width: 150px;
|
||||
}
|
||||
.selected-memory-page h1 {
|
||||
@@ -394,40 +373,6 @@
|
||||
border-left: 2px solid #8da4b3;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.selected-original {
|
||||
padding: 60px 46px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.selected-original > small {
|
||||
font-size: 14px;
|
||||
color: #5e6e7a;
|
||||
}
|
||||
.selected-original > p {
|
||||
margin-top: 70px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.selected-original blockquote {
|
||||
margin: 24px 0 96px;
|
||||
font-size: 24px;
|
||||
line-height: 40px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.selected-original section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.selected-original section > small {
|
||||
font-size: 13px;
|
||||
color: #5e6e7a;
|
||||
}
|
||||
.selected-original > h2 {
|
||||
font-size: 21px;
|
||||
font-weight: 400;
|
||||
margin: 24px 0 32px;
|
||||
}
|
||||
.selected-delete {
|
||||
padding: 130px 66px 0;
|
||||
min-height: 0;
|
||||
@@ -499,10 +444,6 @@
|
||||
.selected-delete > p {
|
||||
margin: 12px 0;
|
||||
}
|
||||
.selected-original {
|
||||
padding-top: 24px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1250px) {
|
||||
.r6-shell .view.r6-memory {
|
||||
@@ -536,7 +477,6 @@
|
||||
.selected-memory-page {
|
||||
padding: 24px;
|
||||
}
|
||||
.selected-original,
|
||||
.selected-delete {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "one-memory",
|
||||
"version": "1.0.5",
|
||||
"release": {
|
||||
"build_number": 17,
|
||||
"build_number": 24,
|
||||
"macos_min_version": "13.0",
|
||||
"app_update": "manual",
|
||||
"runtime_manifest_schema": 1,
|
||||
|
||||
@@ -518,6 +518,11 @@ export interface MemoryDetail {
|
||||
memory: GovernedMemory;
|
||||
evidence: MemoryEvidenceExcerpt[];
|
||||
history: MemoryVersion[];
|
||||
/** SDK-native display metadata. Legacy governed details leave these absent. */
|
||||
sdk_status?: SdkMemoryStatus;
|
||||
source_id?: string | null;
|
||||
sdk_memory_at?: string | null;
|
||||
sdk_created_at?: string | null;
|
||||
// Null for a memory nobody has rewritten: there is no index to be behind,
|
||||
// and the interface should not explain a state the user never caused.
|
||||
index_sync: MemoryIndexSync | null;
|
||||
|
||||
@@ -74,8 +74,13 @@ export const currentChineseMessages = {
|
||||
"memorySpace.tabs.workbench": "工作台",
|
||||
|
||||
"selectedMemory.status.confirmed": "已沉淀",
|
||||
"selectedMemory.status.active": "已生效",
|
||||
"selectedMemory.status.deleted": "已删除",
|
||||
"selectedMemory.status.superseded": "已被取代",
|
||||
"selectedMemory.status.shadow": "已隐藏",
|
||||
"selectedMemory.status.negated": "已否定",
|
||||
"selectedMemory.status.conflicted": "有冲突",
|
||||
"selectedMemory.status.archived": "已归档",
|
||||
"selectedMemory.status.unknown": "状态待核对",
|
||||
"selectedMemory.sourceUnknown": "来源待核对",
|
||||
"selectedMemory.discardDraft": "放弃尚未保存的修改?",
|
||||
@@ -208,8 +213,13 @@ export const currentEnglishMessages: Record<
|
||||
"memorySpace.tabs.workbench": "Workbench",
|
||||
|
||||
"selectedMemory.status.confirmed": "Created",
|
||||
"selectedMemory.status.active": "Active",
|
||||
"selectedMemory.status.deleted": "Deleted",
|
||||
"selectedMemory.status.superseded": "Superseded",
|
||||
"selectedMemory.status.shadow": "Hidden",
|
||||
"selectedMemory.status.negated": "Negated",
|
||||
"selectedMemory.status.conflicted": "Conflicted",
|
||||
"selectedMemory.status.archived": "Archived",
|
||||
"selectedMemory.status.unknown": "Status needs review",
|
||||
"selectedMemory.sourceUnknown": "Source needs review",
|
||||
"selectedMemory.discardDraft": "Discard unsaved changes?",
|
||||
@@ -356,8 +366,13 @@ export const currentSpanishMessages: Record<
|
||||
"memorySpace.tabs.workbench": "Espacio de trabajo",
|
||||
|
||||
"selectedMemory.status.confirmed": "Guardada",
|
||||
"selectedMemory.status.active": "Activa",
|
||||
"selectedMemory.status.deleted": "Eliminada",
|
||||
"selectedMemory.status.superseded": "Sustituida",
|
||||
"selectedMemory.status.shadow": "Oculta",
|
||||
"selectedMemory.status.negated": "Negada",
|
||||
"selectedMemory.status.conflicted": "En conflicto",
|
||||
"selectedMemory.status.archived": "Archivada",
|
||||
"selectedMemory.status.unknown": "Estado pendiente de comprobar",
|
||||
"selectedMemory.sourceUnknown": "Origen sin verificar",
|
||||
"selectedMemory.discardDraft": "¿Descartar los cambios sin guardar?",
|
||||
|
||||
@@ -711,7 +711,7 @@ describe("local Runtime installation", () => {
|
||||
expect(replacement.version_directory).not.toBe(firstPlan.version_directory);
|
||||
});
|
||||
|
||||
it("refuses different bytes from the same commit even with the opt-in", async () => {
|
||||
it("repairs different bytes from the same commit with the packaged opt-in", async () => {
|
||||
if (process.platform !== "darwin") return;
|
||||
const commit = "a".repeat(40);
|
||||
const first = await distribution("1.0.0", {
|
||||
@@ -738,18 +738,17 @@ describe("local Runtime installation", () => {
|
||||
.digest("hex");
|
||||
await writeFile(manifestPath, JSON.stringify(manifest));
|
||||
|
||||
await expect(
|
||||
planLocalInstall({
|
||||
source: rebuilt.source,
|
||||
installRoot: first.installRoot,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
allowSameBuildReplace: true,
|
||||
}),
|
||||
).rejects.toThrow("installed_version_conflict");
|
||||
const plan = await planLocalInstall({
|
||||
source: rebuilt.source,
|
||||
installRoot: first.installRoot,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
allowSameBuildReplace: true,
|
||||
});
|
||||
expect(plan.same_build_replace).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a tampered selected runtime instead of taking it over", async () => {
|
||||
it("replaces a dirty selected runtime from the trusted packaged payload", async () => {
|
||||
if (process.platform !== "darwin") return;
|
||||
const first = await distribution("1.0.0", {
|
||||
buildNumber: 5,
|
||||
@@ -771,15 +770,15 @@ describe("local Runtime installation", () => {
|
||||
gitCommit: "b".repeat(40),
|
||||
});
|
||||
|
||||
await expect(
|
||||
planLocalInstall({
|
||||
source: rebuilt.source,
|
||||
installRoot: first.installRoot,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
allowSameBuildReplace: true,
|
||||
}),
|
||||
).rejects.toThrow("distribution_hash_mismatch");
|
||||
const plan = await planLocalInstall({
|
||||
source: rebuilt.source,
|
||||
installRoot: first.installRoot,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
allowSameBuildReplace: true,
|
||||
});
|
||||
expect(plan.same_build_replace).toBe(true);
|
||||
expect(plan.version_directory).not.toBe(firstPlan.version_directory);
|
||||
});
|
||||
|
||||
it("does not let the same-build opt-in weaken downgrade refusal", async () => {
|
||||
@@ -805,6 +804,32 @@ describe("local Runtime installation", () => {
|
||||
).rejects.toThrow("distribution_downgrade_refused");
|
||||
});
|
||||
|
||||
it("does not let the same-build opt-in replace a tree whose build it cannot read", async () => {
|
||||
if (process.platform !== "darwin") return;
|
||||
const first = await distribution("1.0.0", { buildNumber: 5 });
|
||||
const firstPlan = await planLocalInstall({
|
||||
source: first.source,
|
||||
installRoot: first.installRoot,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
});
|
||||
await applyLocalInstall(firstPlan);
|
||||
await writeFile(
|
||||
path.join(firstPlan.version_directory, "manifest.json"),
|
||||
"{",
|
||||
);
|
||||
const older = await distribution("0.9.0", { buildNumber: 4 });
|
||||
await expect(
|
||||
planLocalInstall({
|
||||
source: older.source,
|
||||
installRoot: first.installRoot,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
allowSameBuildReplace: true,
|
||||
}),
|
||||
).rejects.toThrow("distribution_manifest_invalid");
|
||||
});
|
||||
|
||||
it("accepts an equal build_number when the bytes are identical (idempotent reinstall)", async () => {
|
||||
if (process.platform !== "darwin") return;
|
||||
const first = await distribution("1.0.0", { buildNumber: 5 });
|
||||
|
||||
@@ -367,13 +367,23 @@ async function inspectBuildNumberTransition(options: {
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
throw new Error("install_current_unmanaged");
|
||||
}
|
||||
const selected = options.allowSameBuildReplace
|
||||
? await verifyDistribution(
|
||||
let selectedVerified = true;
|
||||
let selected = await readManifest(target).catch(() => null);
|
||||
if (options.allowSameBuildReplace) {
|
||||
// The opt-in may replace a dirty tree, but only one whose build it can
|
||||
// still read: without a manifest the downgrade check below has nothing to
|
||||
// compare, and a newer runtime would be overwritten by an older one.
|
||||
if (selected === null) throw new Error("distribution_manifest_invalid");
|
||||
try {
|
||||
selected = await verifyDistribution(
|
||||
target,
|
||||
options.incoming.platform,
|
||||
options.incoming.arch,
|
||||
)
|
||||
: await readManifest(target).catch(() => null);
|
||||
);
|
||||
} catch {
|
||||
selectedVerified = false;
|
||||
}
|
||||
}
|
||||
if (selected === null) return { sameBuildReplace: false };
|
||||
// Pre-contract packages have no `build_number`. Comparing them against an
|
||||
// invented ordinal would either break the hourly chain or manufacture a
|
||||
@@ -385,6 +395,9 @@ async function inspectBuildNumberTransition(options: {
|
||||
if (options.incoming.build_number > selected.build_number)
|
||||
return { sameBuildReplace: false };
|
||||
if (options.incoming.build_number === selected.build_number) {
|
||||
if (!selectedVerified && options.allowSameBuildReplace) {
|
||||
return { sameBuildReplace: true };
|
||||
}
|
||||
// Same build: an idempotent reinstall is fine only if the bytes match. The
|
||||
// incoming source is verified, so compare it against the selected tree.
|
||||
try {
|
||||
@@ -395,11 +408,8 @@ async function inspectBuildNumberTransition(options: {
|
||||
}
|
||||
if (
|
||||
!isReleaseCommit(selected.git_commit) ||
|
||||
!isReleaseCommit(options.incoming.git_commit) ||
|
||||
selected.git_commit === options.incoming.git_commit
|
||||
!isReleaseCommit(options.incoming.git_commit)
|
||||
) {
|
||||
// Same commit with different bytes is not a new build: it is a
|
||||
// non-reproducible or corrupted artifact and must stay rejected.
|
||||
throw new Error("installed_version_conflict");
|
||||
}
|
||||
return { sameBuildReplace: true };
|
||||
|
||||
@@ -422,7 +422,7 @@ describe("installPackagedRuntime", () => {
|
||||
).toBe("rebuilt-body");
|
||||
});
|
||||
|
||||
it("refuses same-commit different bytes even when the bundle opts in", async () => {
|
||||
it("repairs same-commit drift when the trusted bundle opts in", async () => {
|
||||
const { root, resources, executable, installRoot } = await fixture();
|
||||
const { selected } = await installFormalCurrent({
|
||||
root,
|
||||
@@ -434,18 +434,43 @@ describe("installPackagedRuntime", () => {
|
||||
await writeFile(path.join(selected, "web", "index.html"), "unexpected");
|
||||
await rewriteManifestDigest(selected, "web/index.html");
|
||||
|
||||
await expect(
|
||||
installPackagedRuntime({
|
||||
resourcesPath: resources,
|
||||
executablePath: executable,
|
||||
installRoot,
|
||||
platform: "darwin",
|
||||
arch: "arm64",
|
||||
buildNumber: 3757,
|
||||
gitCommit: "a".repeat(40),
|
||||
replaceSameBuild: true,
|
||||
}),
|
||||
).rejects.toThrow("installed_version_conflict");
|
||||
const receipt = await installPackagedRuntime({
|
||||
resourcesPath: resources,
|
||||
executablePath: executable,
|
||||
installRoot,
|
||||
platform: "darwin",
|
||||
arch: "arm64",
|
||||
buildNumber: 3757,
|
||||
gitCommit: "a".repeat(40),
|
||||
replaceSameBuild: true,
|
||||
});
|
||||
expect(receipt.reused_current).toBe(false);
|
||||
expect(receipt.version_directory).not.toBe(selected);
|
||||
});
|
||||
|
||||
it("repairs an extra file left in the selected runtime tree", async () => {
|
||||
const { root, resources, executable, installRoot } = await fixture();
|
||||
const { selected } = await installFormalCurrent({
|
||||
root,
|
||||
resources,
|
||||
executable,
|
||||
installRoot,
|
||||
gitCommit: "a".repeat(40),
|
||||
});
|
||||
await writeFile(path.join(selected, "left-by-ditto"), "dirty");
|
||||
|
||||
const receipt = await installPackagedRuntime({
|
||||
resourcesPath: resources,
|
||||
executablePath: executable,
|
||||
installRoot,
|
||||
platform: "darwin",
|
||||
arch: "arm64",
|
||||
buildNumber: 3757,
|
||||
gitCommit: "a".repeat(40),
|
||||
replaceSameBuild: true,
|
||||
});
|
||||
expect(receipt.reused_current).toBe(false);
|
||||
expect(receipt.version_directory).not.toBe(selected);
|
||||
});
|
||||
|
||||
it.each(["missing"])(
|
||||
@@ -483,6 +508,31 @@ describe("installPackagedRuntime", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses to repair a selected tree whose manifest is unreadable", async () => {
|
||||
const { root, resources, executable, installRoot } = await fixture();
|
||||
const { selected } = await installFormalCurrent({
|
||||
root,
|
||||
resources,
|
||||
executable,
|
||||
installRoot,
|
||||
buildNumber: 3758,
|
||||
});
|
||||
await writeFile(path.join(selected, "manifest.json"), "{");
|
||||
|
||||
await expect(
|
||||
installPackagedRuntime({
|
||||
resourcesPath: resources,
|
||||
executablePath: executable,
|
||||
installRoot,
|
||||
platform: "darwin",
|
||||
arch: "arm64",
|
||||
buildNumber: 3757,
|
||||
gitCommit: "a".repeat(40),
|
||||
replaceSameBuild: true,
|
||||
}),
|
||||
).rejects.toThrow("distribution_manifest_invalid");
|
||||
});
|
||||
|
||||
it("preserves downgrade refusal when selected current is newer", async () => {
|
||||
const { root, resources, executable, installRoot } = await fixture();
|
||||
await installFormalCurrent({
|
||||
|
||||
@@ -123,9 +123,9 @@ export async function installPackagedRuntime(options: {
|
||||
*
|
||||
* Off by default, which preserves `installed_version_conflict`. Desktop turns
|
||||
* it on: the bundle it carries has passed notarization, so its payload is
|
||||
* already trusted, and the same build number is re-released whenever a
|
||||
* release is rebuilt from a new commit. Refusing that case leaves an affected
|
||||
* machine with an app that exits on every launch, which is a worse outcome
|
||||
* already trusted. It may need to replace a rebuilt release or repair a
|
||||
* selected tree polluted by an interrupted copy. Refusing either case leaves
|
||||
* an affected machine with an app that exits on every launch, which is worse
|
||||
* than replacing the runtime. The superseded tree is kept, not edited. */
|
||||
replaceSameBuild?: boolean;
|
||||
gitCommit?: string;
|
||||
@@ -307,11 +307,36 @@ async function reuseSelectedSameBuild(options: {
|
||||
) {
|
||||
throw new Error("install_current_unmanaged");
|
||||
}
|
||||
const selectedManifest = await verifyInstalledDistribution(
|
||||
selected,
|
||||
options.platform,
|
||||
options.arch,
|
||||
);
|
||||
let selectedManifest: DistributionManifest;
|
||||
try {
|
||||
selectedManifest = await verifyInstalledDistribution(
|
||||
selected,
|
||||
options.platform,
|
||||
options.arch,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!options.replaceSameBuild) throw error;
|
||||
// A notarized App bundle is the repair source. A dirty selected tree must
|
||||
// reach the normal staged install path instead of failing before the
|
||||
// same-build replacement policy can run.
|
||||
const declared = await readFile(
|
||||
path.join(selected, "manifest.json"),
|
||||
"utf8",
|
||||
)
|
||||
.then((value) => JSON.parse(value) as DistributionManifest)
|
||||
.catch(() => null);
|
||||
// Without a readable build the downgrade check cannot run; fail closed
|
||||
// rather than let an older bundle replace a newer runtime.
|
||||
if (declared === null) throw new Error("distribution_manifest_invalid");
|
||||
if (
|
||||
declared.build_number !== undefined &&
|
||||
options.buildNumber !== undefined &&
|
||||
declared.build_number > options.buildNumber
|
||||
) {
|
||||
throw new Error("distribution_downgrade_refused");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (selectedManifest.build_number !== options.buildNumber) return null;
|
||||
// The bundle's own payload, read in place. It has to agree with the selected
|
||||
// tree file for file; there is no cheaper signal that is also exact, and a
|
||||
|
||||
@@ -83,7 +83,7 @@ it("does not stop Runtime when nothing needs migrating", async () => {
|
||||
expect(fenced).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a finished cleanup as a completed migration", async () => {
|
||||
it("reports a repair of an already migrated home as complete, not a second cutover", async () => {
|
||||
const result = await runSdkMigration({
|
||||
dataDir: "/data",
|
||||
core,
|
||||
@@ -95,7 +95,7 @@ it("treats a finished cleanup as a completed migration", async () => {
|
||||
fence: noFence,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ state: "cutover" });
|
||||
expect(result).toEqual({ state: "complete" });
|
||||
});
|
||||
|
||||
it("reports an unreadable migration status as deferred", async () => {
|
||||
|
||||
@@ -4,7 +4,8 @@ import { withRuntimeMaintenanceFence } from "./runtime-maintenance.js";
|
||||
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
||||
|
||||
export interface SdkMigrationResult {
|
||||
state: "not_required" | "cutover" | "deferred";
|
||||
/** `complete`: already on SDK authority; leftovers were repaired. */
|
||||
state: "not_required" | "cutover" | "complete" | "deferred";
|
||||
code?: string;
|
||||
}
|
||||
|
||||
@@ -84,8 +85,7 @@ export async function runSdkMigration(
|
||||
}
|
||||
if (migrated.exitCode !== 0) return deferred(migrated);
|
||||
const state = migrated.document.state;
|
||||
if (state === "cutover" || state === "complete")
|
||||
return { state: "cutover" };
|
||||
if (state === "cutover" || state === "complete") return { state };
|
||||
if (state === "not_required") return { state: "not_required" };
|
||||
return deferred(migrated);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user