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:
alvinfeiandClaude Opus 5.5 committed 2026-10-25 15:21:31 +08:00
1 parent e6426f6fd0
commit e0920311a1
14 files changed
+364 -151

No files matched your search

+36 -59
View File
@@ -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",
+23 -8
View File
@@ -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(
+11 -3
View File
@@ -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
) {