fix(memhub): close gaps in the Build 23 memory library repair
- Cursor: resume WAL-backed stores from their rowid. The cursor identity now carries the WAL change token, but resume_checkpoint/touched_cursor still compared the bare dev:ino, so every change re-read the store from rowid 0 and reported a rewind. - Library search: leave the proactive intention lane off, list only active hits, bound the per-hit client.get fan-out, and count skipped rows from the single page parse instead of re-validating the page. - Metadata repair: pool evidence per SDK memory so disagreeing sources stay unknown instead of last-output-wins; close the legacy, coding and Chroma handles; register both openings in the main-database gate, which the previous commit left failing. - Runtime: the same-build opt-in fails closed when the selected tree's manifest is unreadable, so it cannot bypass downgrade refusal. - Detail: keep the heading within 72 characters and break English text at a word boundary. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e6426f6fd0
commit
e0920311a1
14 files changed
+364
-151
No files matched your search
@@ -12,7 +12,6 @@ from collections.abc import (
|
||||
Awaitable,
|
||||
Callable,
|
||||
Iterable,
|
||||
Mapping,
|
||||
MutableMapping,
|
||||
)
|
||||
from contextlib import asynccontextmanager, nullcontext, suppress
|
||||
@@ -104,6 +103,7 @@ from one_memory.hy_sdk_types import (
|
||||
HySdkMemoryRecord,
|
||||
HySdkRawPage,
|
||||
HySdkRawRecord,
|
||||
HySdkReadError,
|
||||
HySdkUserProfile,
|
||||
parse_sdk_search,
|
||||
)
|
||||
@@ -3081,13 +3081,8 @@ def create_app(
|
||||
offset=0,
|
||||
order="desc",
|
||||
)
|
||||
page = HySdkMemoryPage.from_sdk(
|
||||
raw,
|
||||
limit=1,
|
||||
offset=0,
|
||||
skip_invalid=True,
|
||||
)
|
||||
_log_invalid_sdk_memories(raw)
|
||||
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(
|
||||
@@ -3172,7 +3167,9 @@ def create_app(
|
||||
min_score=sdk_relevance_floor(),
|
||||
schema_min_score=sdk_relevance_floor(),
|
||||
schema_limit=fetch,
|
||||
intention_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,
|
||||
)
|
||||
@@ -3180,27 +3177,22 @@ def create_app(
|
||||
search_raw,
|
||||
error_code="hy_memory_search_shape_unknown",
|
||||
)
|
||||
selected = hits[offset : offset + limit]
|
||||
rows = await asyncio.gather(
|
||||
*(
|
||||
asyncio.to_thread(client.get, hit.memory_id)
|
||||
for hit in selected
|
||||
)
|
||||
rows = await _get_sdk_rows(
|
||||
client, [hit.memory_id for hit in hits[offset : offset + limit]]
|
||||
)
|
||||
memories: list[HySdkMemoryRecord] = []
|
||||
invalid_count = 0
|
||||
skipped = 0
|
||||
for row in rows:
|
||||
if row is None:
|
||||
continue
|
||||
try:
|
||||
memories.append(HySdkMemoryRecord.from_sdk(row))
|
||||
except HySdkError:
|
||||
invalid_count += 1
|
||||
if invalid_count:
|
||||
logger.warning(
|
||||
"sdk_memory_invalid_rows_skipped count=%d",
|
||||
invalid_count,
|
||||
)
|
||||
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),
|
||||
@@ -3215,13 +3207,10 @@ def create_app(
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
page = HySdkMemoryPage.from_sdk(
|
||||
raw,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
skip_invalid=True,
|
||||
page, skipped = HySdkMemoryPage.from_sdk_skipping_invalid(
|
||||
raw, limit=limit, offset=offset
|
||||
)
|
||||
_log_invalid_sdk_memories(raw)
|
||||
_log_skipped_sdk_memories(skipped)
|
||||
return page
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -3247,13 +3236,8 @@ def create_app(
|
||||
raw = await asyncio.to_thread(
|
||||
client.list_memories, user_id="local", limit=limit, offset=0, order="desc"
|
||||
)
|
||||
page = HySdkMemoryPage.from_sdk(
|
||||
raw,
|
||||
limit=limit,
|
||||
offset=0,
|
||||
skip_invalid=True,
|
||||
)
|
||||
_log_invalid_sdk_memories(raw)
|
||||
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
|
||||
@@ -3281,9 +3265,7 @@ def create_app(
|
||||
nodes[source_node_id] = MemoryGraphNode(
|
||||
node_id=source_node_id,
|
||||
node_type="source",
|
||||
label=(
|
||||
registration.descriptor.display_name if registration else memory.source
|
||||
),
|
||||
label=(registration.descriptor.display_name if registration else memory.source),
|
||||
source_id=memory.source,
|
||||
)
|
||||
edges.append(
|
||||
@@ -5661,29 +5643,24 @@ def _sdk_client_handle(app: FastAPI) -> SdkClientHandle:
|
||||
return handle
|
||||
|
||||
|
||||
def _log_invalid_sdk_memories(raw: object) -> None:
|
||||
count = _invalid_sdk_memory_count(raw)
|
||||
def _log_skipped_sdk_memories(count: int) -> None:
|
||||
if count:
|
||||
logger.warning("sdk_memory_invalid_rows_skipped count=%d", count)
|
||||
|
||||
|
||||
def _invalid_sdk_memory_count(raw: object) -> int:
|
||||
if not isinstance(raw, Mapping):
|
||||
return 0
|
||||
found = 0
|
||||
for bucket_name, rows_name in (("vdb", "memories"), ("graph", "nodes")):
|
||||
bucket = raw.get(bucket_name)
|
||||
if not isinstance(bucket, Mapping):
|
||||
continue
|
||||
rows = bucket.get(rows_name)
|
||||
if not isinstance(rows, list):
|
||||
continue
|
||||
for row in rows:
|
||||
try:
|
||||
HySdkMemoryRecord.from_sdk(row)
|
||||
except HySdkError:
|
||||
found += 1
|
||||
return found
|
||||
# `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(
|
||||
|
||||
@@ -10,6 +10,7 @@ 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
|
||||
@@ -83,10 +84,7 @@ def repair_sdk_metadata_from_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"
|
||||
)
|
||||
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(
|
||||
@@ -144,13 +142,11 @@ def _sdk_migration_id(database_path: Path) -> str | None:
|
||||
|
||||
|
||||
def _legacy_metadata(path: Path, *, now: datetime) -> dict[str, RepairMetadata]:
|
||||
with sqlite3.connect(f"file:{path}?mode=ro", uri=True) as database:
|
||||
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'"
|
||||
)
|
||||
for row in database.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
if not {"memory_task_outputs", "governed_memories", "evidence"} <= tables:
|
||||
return {}
|
||||
@@ -170,8 +166,7 @@ def _legacy_metadata(path: Path, *, now: datetime) -> dict[str, RepairMetadata]:
|
||||
tuple(
|
||||
str(ref["evidence_id"])
|
||||
for ref in refs
|
||||
if isinstance(ref, dict)
|
||||
and isinstance(ref.get("evidence_id"), str)
|
||||
if isinstance(ref, dict) and isinstance(ref.get("evidence_id"), str)
|
||||
)
|
||||
if isinstance(refs, list)
|
||||
else ()
|
||||
@@ -204,21 +199,31 @@ def _legacy_metadata(path: Path, *, now: datetime) -> dict[str, RepairMetadata]:
|
||||
"SELECT memory_id,created_at,updated_at FROM governed_memories"
|
||||
)
|
||||
}
|
||||
result: dict[str, RepairMetadata] = {}
|
||||
# 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:
|
||||
resolved = [evidence[identifier] for identifier in refs if identifier in evidence]
|
||||
sources = {source for source, _timestamp in resolved if source and source != "default"}
|
||||
times = [timestamp for _source, timestamp in resolved if timestamp is not None]
|
||||
result[canonical_id] = RepairMetadata(
|
||||
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(governed[legacy_id].timestamp())
|
||||
if governed.get(legacy_id) is not None
|
||||
else None
|
||||
),
|
||||
created_at=int(min(created).timestamp()) if created else None,
|
||||
)
|
||||
return result
|
||||
for canonical_id, (sources, times, created) in pooled.items()
|
||||
}
|
||||
|
||||
|
||||
def _plausible_time(
|
||||
@@ -256,50 +261,55 @@ def _repair_vector(
|
||||
path=str(directory),
|
||||
settings=Settings(anonymized_telemetry=False),
|
||||
)
|
||||
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
|
||||
# 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,
|
||||
):
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
@@ -313,12 +323,10 @@ def _repair_coding(
|
||||
path = root / name
|
||||
if not path.is_file():
|
||||
continue
|
||||
with sqlite3.connect(path) as database:
|
||||
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 row in database.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
for table, identifier in (
|
||||
("coding_memory_meta", "experience_id"),
|
||||
@@ -351,8 +359,7 @@ def _repair_coding(
|
||||
continue
|
||||
values.append(memory_id)
|
||||
database.execute(
|
||||
f"UPDATE {table} SET {','.join(updates)} "
|
||||
f"WHERE {identifier}=?",
|
||||
f"UPDATE {table} SET {','.join(updates)} WHERE {identifier}=?",
|
||||
values,
|
||||
)
|
||||
repaired += 1
|
||||
|
||||
@@ -94,14 +94,30 @@ class HySdkMemoryPage(BaseModel):
|
||||
offset: int = Field(ge=0)
|
||||
|
||||
@classmethod
|
||||
def from_sdk(
|
||||
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 = False,
|
||||
) -> HySdkMemoryPage:
|
||||
skip_invalid: bool,
|
||||
) -> tuple[HySdkMemoryPage, int]:
|
||||
if not isinstance(raw, Mapping):
|
||||
raise HySdkReadError("hy_memory_list_shape_unknown")
|
||||
rows: list[object] = []
|
||||
@@ -122,15 +138,17 @@ class HySdkMemoryPage(BaseModel):
|
||||
total += bucket_total
|
||||
rows.extend(bucket[rows_name])
|
||||
deduplicated: dict[str, HySdkMemoryRecord] = {}
|
||||
skipped = 0
|
||||
for row in rows:
|
||||
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
|
||||
@@ -140,6 +158,7 @@ class HySdkMemoryPage(BaseModel):
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return page, skipped
|
||||
|
||||
|
||||
class HySdkRawRecord(BaseModel):
|
||||
|
||||
@@ -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
|
||||
@@ -1056,12 +1056,25 @@ def source_cursor(
|
||||
|
||||
def _cursor_file_identity(source_file: AuthorizedSourceFile) -> str:
|
||||
return (
|
||||
f"{source_file.file_identity}:v:{source_file.cursor_version}"
|
||||
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+(?=:|$)")
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -302,6 +302,17 @@ def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutatio
|
||||
"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",
|
||||
@@ -335,9 +346,7 @@ def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutatio
|
||||
]
|
||||
include_sources = kwargs.get("include_sources") or []
|
||||
if include_sources:
|
||||
rows = [
|
||||
row for row in rows if row["source"] in include_sources
|
||||
]
|
||||
rows = [row for row in rows if row["source"] in include_sources]
|
||||
return {
|
||||
"vdb": {
|
||||
"memories": rows,
|
||||
@@ -358,7 +367,12 @@ def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutatio
|
||||
"memory_id": "release",
|
||||
"content": "Release plan",
|
||||
"score": 0.9,
|
||||
}
|
||||
},
|
||||
{
|
||||
"memory_id": "retired",
|
||||
"content": "Release plan, first draft",
|
||||
"score": 0.8,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -388,13 +402,14 @@ def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutatio
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["total"] == 1
|
||||
assert [item["memory_id"] for item in body["memories"]] == [
|
||||
"release"
|
||||
]
|
||||
# `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(
|
||||
|
||||
@@ -164,6 +164,36 @@ def test_cursor_wal_change_token_does_not_become_a_future_conversation_time(
|
||||
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 = (
|
||||
|
||||
@@ -5,6 +5,7 @@ import hashlib
|
||||
import io
|
||||
import json
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
@@ -74,8 +75,7 @@ def test_post_cutover_metadata_repair_restores_source_and_original_times(store):
|
||||
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",
|
||||
"UPDATE memory_authority_state SET authority='sdk',migration_id=? WHERE singleton=1",
|
||||
(migration_id,),
|
||||
)
|
||||
legacy = (
|
||||
@@ -186,6 +186,60 @@ def test_post_cutover_metadata_repair_restores_source_and_original_times(store):
|
||||
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:
|
||||
|
||||
@@ -188,6 +188,29 @@ it("splits long memory text into a bounded heading and readable body", () => {
|
||||
"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(
|
||||
|
||||
@@ -16,6 +16,8 @@ import "./selected-memory.css";
|
||||
|
||||
type Page = "read" | "edit" | "delete";
|
||||
|
||||
const HEADING_LIMIT = 72;
|
||||
|
||||
export function SelectedMemoryDetail({
|
||||
detail,
|
||||
sources,
|
||||
@@ -234,12 +236,18 @@ export function SelectedMemoryDetail({
|
||||
if (alive.current) setBusy(false);
|
||||
}
|
||||
}
|
||||
// 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 < 85
|
||||
punctuation > 0 && punctuation < HEADING_LIMIT
|
||||
? punctuation + 1
|
||||
: memory.content.length > 72
|
||||
? 72
|
||||
: memory.content.length > HEADING_LIMIT
|
||||
? space > HEADING_LIMIT / 2
|
||||
? space
|
||||
: HEADING_LIMIT
|
||||
: -1;
|
||||
const heading =
|
||||
split > 0 ? memory.content.slice(0, split).trim() : memory.content;
|
||||
|
||||
@@ -804,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 });
|
||||
|
||||
@@ -369,7 +369,11 @@ async function inspectBuildNumberTransition(options: {
|
||||
}
|
||||
let selectedVerified = true;
|
||||
let selected = await readManifest(target).catch(() => null);
|
||||
if (options.allowSameBuildReplace && selected !== 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,
|
||||
|
||||
@@ -508,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({
|
||||
|
||||
@@ -325,8 +325,11 @@ async function reuseSelectedSameBuild(options: {
|
||||
)
|
||||
.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 &&
|
||||
declared.build_number !== undefined &&
|
||||
options.buildNumber !== undefined &&
|
||||
declared.build_number > options.buildNumber
|
||||
) {
|
||||
|
||||
Reference in new issue
Block a user