Compare commits

...
Author SHA1 Message Date
kristinhe 01394b75da fix(memhub): open an SDK memory where the map put it
On SDK authority, clicking a memory switched the whole page to the SDK
library — a second screen made of status tabs and a ledger, where the memory
page's design has one list, one graph and one inspector. Those three already
exist: they are the components the mock-data screen was built from. This wires
the real SDK reads into them instead of standing a second screen beside them.

- opening a memory reads it through v2 and draws it in the map's inspector;
  edits and deletes go to updateSdkMemory / deleteSdkMemory, and a write
  re-reads the page instead of waiting for the poll
- the left list gets a real library page (50 a page, with the total and each
  memory's source and conversation time), so the count, the paging footer and
  the provenance line read the way the design reads
- a memory with no cited excerpt no longer renders an evidence block and a
  source line that can only say "not available"

The SDK library panel is left as it was; it is no longer on this path.
2026-10-25 01:35:39 +08:00
kristinhe 667c8754c6 fix(memhub): draw the SDK memory library with the shared memory ledger
On SDK authority the memory page's library panel rendered SdkMemoryLibrary,
a stopgap that printed `<li><button>{content}</button></li>` with no styling:
`.sdk-memory-library` had no rule in any stylesheet, so every row fell back to
the global button look (dark slab, centred text) with the browser's list
markers. #170 made the list reachable, which is when it became visible.

Render the panel with the markup the governed page already uses — the status
tabs, the `共 N 条 / 第 N 页 / 上一页 / 下一页` row, `memory-ledger` rows with a
`kind · status · time · tags` line, and `memory-inspector` for the detail. No
new CSS: the classes are the shipped design, so both authorities now read the
same. The inspector keeps its empty state instead of collapsing the column.

Search: `/api/v2/memory-library` takes no text query and `refresh()` returns
early off legacy authority, so the header box silently did nothing. Scan the
pages once (500 rows per call, cached per tab) and filter locally, with the
count showing matches.

Details open through v2 and stay editable only while active, as before.
2026-10-25 01:00:42 +08:00
feitianxiang 0aeb3e8760 Merge pull request #167 from Tencent-Hunyuan/fix/simplify-sdk-migration
fix(memhub): migrate to SDK authority without copying memory
2026-10-24 23:18:32 +08:00
feitianxiang 70aaa7be85 Merge pull request #170 from Tencent-Hunyuan/fix/upgrade-runtime-and-sdk-memory-map
fix(memhub): install the bundled Runtime on upgrade and show SDK memories
2026-10-24 23:12:03 +08:00
alvinfeiandClaude Opus 5.5 18046152ab feat(memhub): draw the memory map from SDK memories on SDK authority
The default memory view is the map, and on SDK authority it had nothing
to draw: its list and graph read only legacy authority. It now reads SDK
memories (`/api/v2/memory-library`) and `/api/v2/memories/graph` and
draws them with the same map model, so the map stays the default view.
Opening a memory from the map goes to the SDK library, which reads,
edits and deletes it through v2; governed detail is refused there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 22:22:55 +08:00
alvinfeiandClaude Opus 5.5 7b946a28ef 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>
2026-10-24 22:08:14 +08:00
feitianxiang 0c36a6c960 Merge pull request #168 from Tencent-Hunyuan/feat/single-sdk-migrate-command
feat(memhub): migrate every install with one sdk-migrate command
2026-10-24 11:47:25 +08:00
14 changed files with 1072 additions and 111 deletions

No files matched your search

+4 -2
View File
@@ -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
+128 -1
View File
@@ -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"
+52 -25
View File
@@ -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,
);
});
});
+31
View File
@@ -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,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);
});
+275 -54
View File
@@ -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,108 @@ 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 [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"], offset: sdkLibraryOffset, limit: 50 },
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,
sdkMapRevision,
]);
const [draft, setDraft] = useState("");
const [selection, setSelection] = useState<Set<string>>(new Set());
const bulkInFlight = useRef(false);
@@ -969,8 +1085,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 +1136,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 +1190,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 +1418,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 +1611,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 +1999,39 @@ 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: "",
page: sdkLibrary.page,
loading: sdkLibrary.loading,
failed: sdkLibrary.failed,
stale: false,
onQuery: setSdkLibraryQuery,
// SDK memories carry one source each and the list is paged by
// the endpoint; a source filter would have to page separately.
onSource: () => {},
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 +2039,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}
+34 -24
View File
@@ -249,9 +249,14 @@ export function SelectedMemoryDetail({
</p>
<h3>{heading}</h3>
{body && <p className="selected-memory-body">{body}</p>}
<p className="selected-memory-source">
{t("selectedMemory.sourceLine", { source, time: timestamp })}
</p>
{/* The source line names the conversation a memory was read from.
Without a cited excerpt there is no such conversation to name, and
the row would say "unknown" about a memory that is right here. */}
{detail.evidence.length ? (
<p className="selected-memory-source">
{t("selectedMemory.sourceLine", { source, time: timestamp })}
</p>
) : null}
<p className="selected-memory-times">
{t("selectedMemory.timestamps", {
conversation: clockStamp(
@@ -276,27 +281,32 @@ 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>
{/* An SDK memory carries no cited excerpt, so the section would only
say "not available". The design shows the memory itself; keep it
that way rather than opening a block about evidence nobody kept. */}
{detail.evidence.length ? (
<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>
) : null}
{!hideActions && (
<R6Button variant="quiet" onClick={() => setShowHistory((v) => !v)}>
{t("selectedMemory.history")}
+11
View File
@@ -1162,6 +1162,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> {
+105
View File
@@ -0,0 +1,105 @@
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],
revision: 1,
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 (memory + cited evidence +
* versions). The SDK keeps none of the latter two per memory — a rewrite
* replaces the row — so they are empty rather than faked, and the panel shows
* the memory itself where the design put it.
*/
export function sdkDetail(record: SdkMemoryRecord): MemoryDetail {
return {
memory: sdkMapMemory(record),
evidence: [],
history: [],
index_sync: 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) 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,
};
}
@@ -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);
});