fix(memhub): install the bundled Runtime on upgrade and show SDK memories
Build 21 acceptance found an upgraded App on the previous Runtime and a memory page that never left "reading": - Desktop skipped `installPackagedRuntime` whenever the service owner was "cli", which it also assumes itself when a plist exists without an owner receipt. The App now always installs its bundled Runtime (a newer build from `memhub upgrade` is kept) and refuses to start on a `current` older than its own build (`packaged_runtime_not_current`). The startup failure event now records the cause, with the home path shortened. - On SDK authority the default memory map loaded nothing: its list and graph read only legacy authority. The memory page now opens the v2 SDK library there, and `/api/v2/memories/graph` serves the map from SDK memories plus the Kuzu overlay. A failed authority probe is retried instead of leaving the page unknown forever. - Every completed SDK write logged RetiredAuthorityAccess: the retired Evidence facade raised from `getattr(..., None)`. It now has a no-op `release_sdk_staging`. - A repair of an already migrated home is reported as `complete`, not a second `cutover`, and the CLI treats it as non-rollbackable too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e5ce0cd964
commit
7b946a28ef
11 files changed
+508
-66
No files matched your search
@@ -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
|
||||
|
||||
@@ -3103,7 +3103,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",
|
||||
@@ -3128,6 +3131,130 @@ def create_app(
|
||||
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 = HySdkMemoryPage.from_sdk(raw, limit=limit, offset=0)
|
||||
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:
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -866,3 +866,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", () => {
|
||||
@@ -312,3 +314,43 @@ describe("startup dialog body", () => {
|
||||
expect(detail).not.toContain("sdk_migration_disk_space_insufficient");
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
@@ -205,6 +206,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:
|
||||
@@ -252,3 +255,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);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { MemorySpace } from "./MemorySpace";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
authority: vi.fn(),
|
||||
legacyLibrary: vi.fn(),
|
||||
graph: vi.fn(),
|
||||
}));
|
||||
vi.mock("./api", () => ({
|
||||
getMemoryAuthority: api.authority,
|
||||
getMemoryLibrary: api.legacyLibrary,
|
||||
getMemories: async () => [],
|
||||
getMemoryGraph: api.graph,
|
||||
getMemoryProcessing: async () => null,
|
||||
getModelProfiles: async () => [],
|
||||
apiErrorCode: () => null,
|
||||
}));
|
||||
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
|
||||
vi.mock("./MemoryNoteComposer", () => ({ MemoryNoteComposer: () => null }));
|
||||
vi.mock("./MemoryMap", () => ({
|
||||
MemoryMap: ({ hidden }: { hidden: boolean }) => (
|
||||
<div hidden={hidden} data-testid="legacy-map" />
|
||||
),
|
||||
}));
|
||||
vi.mock("./SdkMemoryLibrary", () => ({
|
||||
SdkMemoryLibrary: () => <div data-testid="sdk-library" />,
|
||||
}));
|
||||
vi.mock("./MemoryRecallScope", () => ({
|
||||
MemoryRecallScope: () => null,
|
||||
memoryProvenanceLabel: () => "原话",
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("opens the SDK library on SDK authority instead of a map that cannot load", async () => {
|
||||
api.authority.mockResolvedValue({ authority: "sdk", migration_id: "m" });
|
||||
|
||||
render(<MemorySpace simplified active />);
|
||||
|
||||
const library = await screen.findByTestId("sdk-library");
|
||||
expect(library.closest("[hidden]")).toBeNull();
|
||||
expect(screen.getByTestId("legacy-map").hidden).toBe(true);
|
||||
expect(api.legacyLibrary).not.toHaveBeenCalled();
|
||||
expect(api.graph).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText("正在读取…")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps asking for the authority until Core answers", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
api.authority
|
||||
.mockRejectedValueOnce(new Error("core_not_ready"))
|
||||
.mockRejectedValueOnce(new Error("core_not_ready"))
|
||||
.mockResolvedValue({ authority: "sdk", migration_id: "m" });
|
||||
|
||||
render(<MemorySpace simplified active />);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
|
||||
await waitFor(() => expect(api.authority).toHaveBeenCalledTimes(3));
|
||||
expect(await screen.findByTestId("sdk-library")).toBeTruthy();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
@@ -159,8 +159,12 @@ export function MemorySpace({
|
||||
"unknown" | "legacy" | "sdk"
|
||||
>("unknown");
|
||||
const [managementOpen, setManagementOpen] = useState(false);
|
||||
// On SDK authority the map still reads the legacy library and governed
|
||||
// detail, which the API refuses, so it would wait forever. The SDK library
|
||||
// lists, opens, edits and deletes through v2; show it until the map reads
|
||||
// SDK memories too.
|
||||
const displayedMode = simplified
|
||||
? managementOpen
|
||||
? managementOpen || memoryAuthority === "sdk"
|
||||
? "library"
|
||||
: "map"
|
||||
: mode;
|
||||
@@ -168,13 +172,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(() => {
|
||||
@@ -1444,33 +1458,35 @@ export function MemorySpace({
|
||||
{simplified ? t("memory:记忆") : t("memory:记忆空间")}
|
||||
</h1>
|
||||
<span className="memory-loaded-count">
|
||||
{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} 条记忆",
|
||||
memories.filter((m) => m.status !== "deleted").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} 条记忆",
|
||||
memories.filter((m) => m.status !== "deleted").length,
|
||||
)
|
||||
: t("memory:正在读取…")}
|
||||
</span>
|
||||
</div>
|
||||
<MemoryNoteComposer
|
||||
|
||||
@@ -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