fix(memhub): harden memory overview recovery

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kristinheandCursor committed 2026-10-28 19:26:01 +08:00
1 parent 082aeee1f2
commit 5103492762
10 files changed
+242 -13

No files matched your search

+12
View File
@@ -3004,6 +3004,12 @@ def create_app(
_log_skipped_sdk_memories(skipped)
except Exception as error:
raise _sdk_http_error(error, "hy_memory_list_failed") from None
revoked_sources = await asyncio.to_thread(
_read_access(app).revoked_source_ids,
user_id="local",
)
if source in revoked_sources:
raise HTTPException(status_code=409, detail="memory_source_revoked")
vdb = raw.get("vdb") if isinstance(raw, dict) else None
total = vdb.get("total") if isinstance(vdb, dict) else None
visible = [memory for memory in page.memories if memory.layer != "l1_raw"]
@@ -3100,6 +3106,12 @@ def create_app(
origin="hy_relation",
)
)
final_revoked_sources = await asyncio.to_thread(
_read_access(app).revoked_source_ids,
user_id="local",
)
if final_revoked_sources != revoked_sources:
raise HTTPException(status_code=409, detail="memory_source_access_changed")
return MemoryGraphResponse(
total_memories=(
total
@@ -1295,6 +1295,67 @@ def test_the_sdk_memory_map_draws_sdk_memories_without_legacy_tables(tmp_path) -
assert graph["hy_graph_available"] is False
@pytest.mark.integration
@pytest.mark.fresh_sdk
def test_sdk_memory_graph_rechecks_revocation_after_the_sdk_read(tmp_path) -> None:
async def scenario():
app = create_app(_app_settings(tmp_path), secret_store=InMemorySecretStore())
database = app.state.settings.data_dir / "one-memory.sqlite3"
class Client:
def list_memories(self, **_kwargs):
with sqlite3.connect(database) as db:
db.execute(
"INSERT INTO source_grants "
"(user_id,source_id,root_path,requested_path,root_identity,"
"candidate_kind,granted_at,revoked_at,revision) VALUES "
"('local','cursor','/fixture','/fixture','fixture','default',"
"'2026-10-01T00:00:00Z','2026-10-02T00:00:00Z',1)"
)
return {
"vdb": {
"memories": [
{
"memory_id": "revoked-during-read",
"content": "must not escape",
"layer": "l2_fact",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"session_id": "s1",
"source": "cursor",
}
],
"total": 1,
}
}
class Handle:
async def client(self):
return Client()
async def close(self):
return None
async with (
app.router.lifespan_context(app),
AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": "Bearer authority-test"},
) as client,
):
app.state.sdk_client_handle = Handle()
response = await client.get(
"/api/v2/memories/graph",
params={"source": "cursor", "limit": 100},
)
assert response.status_code == 409
assert response.json()["detail"] == "memory_source_revoked"
asyncio.run(scenario())
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
@@ -811,6 +811,84 @@ it("offers a calm retry after repeated home summary failures", async () => {
vi.useRealTimers();
}
});
it("counts repeated stale summaries and recovers on the next confirmed read", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const stale = {
memory_count: 0,
latest: null,
observed_at: "2026-09-19T00:00:00Z",
stale: true,
};
summary
.mockResolvedValueOnce(stale)
.mockResolvedValueOnce(stale)
.mockResolvedValue({
memory_count: 4,
latest: null,
observed_at: "2026-09-19T00:01:00Z",
});
render(
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={() => {}}
onSettings={() => {}}
/>,
);
await waitFor(() =>
expect(screen.getByText("后台正在启动,稍后自动更新")).toBeTruthy(),
);
await act(async () => vi.advanceTimersByTimeAsync(10_000));
expect(await screen.findByText("记忆概览还没准备好")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "重新读取" }));
expect(await screen.findByText("4")).toBeTruthy();
expect(screen.queryByText("记忆概览还没准备好")).toBeNull();
} finally {
vi.useRealTimers();
}
});
it("labels a retained count as previous data after repeated stale reads", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const stale = {
memory_count: 0,
latest: null,
observed_at: "2026-09-19T00:00:00Z",
stale: true,
};
summary
.mockResolvedValueOnce({
memory_count: 601,
latest: null,
observed_at: "2026-09-19T00:00:00Z",
})
.mockResolvedValueOnce(stale)
.mockResolvedValueOnce(stale)
.mockResolvedValue({
memory_count: 4,
latest: null,
observed_at: "2026-09-19T00:01:00Z",
});
render(
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={() => {}}
onSettings={() => {}}
/>,
);
expect(await screen.findByText("601")).toBeTruthy();
await act(async () => vi.advanceTimersByTimeAsync(20_000));
expect(screen.getByText("601")).toBeTruthy();
expect(screen.getByText("正在显示上次数据,稍后自动更新")).toBeTruthy();
await act(async () => vi.advanceTimersByTimeAsync(10_000));
expect(await screen.findByText("4")).toBeTruthy();
expect(screen.queryByText("正在显示上次数据,稍后自动更新")).toBeNull();
} finally {
vi.useRealTimers();
}
});
it("never calls a recall event a success when only stored memories are available", async () => {
render(
<ApplicationsHome
+13 -6
View File
@@ -554,11 +554,15 @@ export function ApplicationsHome({
busy = false;
if (stopped) return;
if (home.status === "fulfilled") {
setSummaryFailures(0);
// stale=true 表示这轮读取超时,数字不代表现状:保留上一次的概览,
// 等 10 秒后的下一轮轮询补上,绝不清零也不报错。
if (!("stale" in home.value) || !home.value.stale)
// 等 10 秒后的下一轮轮询补上。重复 stale 仍要进入可恢复状态,
// 不能永远停在“正在核对”。
if (!("stale" in home.value) || !home.value.stale) {
setSummaryFailures(0);
setSummary(home.value);
} else {
setSummaryFailures((count) => count + 1);
}
} else {
setSummaryFailures((count) => count + 1);
}
@@ -579,6 +583,7 @@ export function ApplicationsHome({
const visible = summary;
const latest = visible?.latest;
const summaryUnavailable = visible === null && summaryFailures >= 2;
const summaryStale = visible !== null && summaryFailures >= 2;
const hasCall =
recentCall !== null &&
(callState === "ready" || callState === "incomplete");
@@ -821,7 +826,7 @@ export function ApplicationsHome({
<h2>{t("apps.overview")}</h2>
</header>
<div className="r6-home-metrics">
<div>
<div role="status" aria-live="polite" aria-atomic="true">
<p>{t("apps.memories")}</p>
<p className="r6-home-figure">
<strong>
@@ -831,9 +836,11 @@ export function ApplicationsHome({
</strong>
{t("apps.unit") ? <i>{t("apps.unit")}</i> : null}
</p>
<small {...(visible ? {} : { role: "status" })}>
<small>
{visible
? t("apps.scope")
? summaryStale
? t("apps.showingPrevious")
: t("apps.scope")
: summaryUnavailable
? t("apps.countWillUpdate")
: summaryFailures
+27
View File
@@ -498,6 +498,33 @@ it("does not show the previous source graph while a searched source is changing"
expect(screen.getAllByText("正在核对来源…")).toHaveLength(2);
});
it("shows a retry instead of collapsing a failed graph to the current page", async () => {
const onRetry = vi.fn();
render(
<Host
simplified
libraryView={{
...pagedView({
items: memories,
total: memories.length,
offset: 0,
limit: 20,
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
}),
graphFailed: true,
onRetry,
}}
/>,
);
expect(screen.queryByTestId("canvas")).toBeNull();
expect(screen.getByText("记忆暂时无法读取")).toBeTruthy();
expect(screen.getByRole("status").textContent).toBe("记忆暂时无法读取");
await userEvent.click(
screen.getByRole("button", { name: "重新读取地图记忆" }),
);
expect(onRetry).toHaveBeenCalledOnce();
});
it("starts with receipt-backed source grouping, keeping topics secondary", () => {
render(<Host />);
expect(
+14 -3
View File
@@ -96,6 +96,7 @@ type Props = {
page: MemoryLibraryPage | null;
loading: boolean;
failed: boolean;
graphFailed?: boolean;
stale: boolean;
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
unattributedSource?: string;
@@ -308,7 +309,7 @@ export function MemoryMap(props: Props) {
[active, group, search, serverPaged, axis, groupKey],
);
const projection = useMemo(() => {
if (serverPaged && library?.stale)
if (serverPaged && (library?.stale || library?.graphFailed))
return {
total_memories: library.page?.total ?? 0,
projected_memories: 0,
@@ -374,6 +375,7 @@ export function MemoryMap(props: Props) {
serverPaged,
library?.query,
library?.stale,
library?.graphFailed,
library?.page?.total,
]);
const visibleGroupNode = groupNode
@@ -1046,10 +1048,14 @@ export function MemoryMap(props: Props) {
) : (
<div className="map-empty-stage">
<Network size={32} strokeWidth={1} />
<p>
<p
{...(library?.graphFailed
? { role: "status", "aria-live": "polite" as const }
: {})}
>
{library?.stale
? text("正在核对来源…")
: library?.failed
: library?.graphFailed || library?.failed
? text("记忆暂时无法读取")
: library?.page &&
(library.query || library.source || groupKey)
@@ -1062,6 +1068,11 @@ export function MemoryMap(props: Props) {
? text("已保存的对话还未生成记忆")
: text("还没有可显示的记忆")}
</p>
{library?.graphFailed ? (
<R6Button onClick={library.onRetry}>
{text("重新读取地图记忆")}
</R6Button>
) : null}
</div>
)}
</div>
@@ -64,6 +64,7 @@ vi.mock("./MemoryMap", () => ({
source_ids_by_memory?: Record<string, string[]>;
offset: number;
} | null;
graphFailed?: boolean;
stale: boolean;
unattributedSource?: string;
onSource(value: string): void;
@@ -94,6 +95,9 @@ vi.mock("./MemoryMap", () => ({
<span data-testid="map-detail">{detail?.memory.memory_id ?? ""}</span>
<span data-testid="map-offset">{libraryView?.page?.offset ?? ""}</span>
<span data-testid="map-stale">{String(libraryView?.stale)}</span>
<span data-testid="map-graph-failed">
{String(libraryView?.graphFailed)}
</span>
<button type="button" onClick={() => onOpenMemory("e1")}>
open e1
</button>
@@ -231,6 +235,18 @@ it("draws the map from SDK memories on SDK authority, without legacy reads", asy
);
});
it("keeps a graph read failure explicit instead of presenting the list page as the graph", async () => {
sdkAuthority();
api.sdkGraph.mockRejectedValue(new Error("graph unavailable"));
render(<MemorySpace simplified active />);
await waitFor(() =>
expect(screen.getByTestId("map-loaded").textContent).toBe("true"),
);
expect(screen.getByTestId("map-graph-failed").textContent).toBe("true");
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
});
it("keeps rows, sources, count and graph on the one filtered page", async () => {
sdkAuthority();
render(<MemorySpace simplified active />);
+16 -4
View File
@@ -171,6 +171,7 @@ export function MemorySpace({
// filter change cannot paint the previous filter's graph during the new read.
const [sdkGraph, setSdkGraph] = useState<{
scope: string;
status: "ready" | "failed";
value: MemoryGraphResponse | null;
} | null>(null);
// Bumping this re-reads the page and graph after an edit or a delete.
@@ -199,7 +200,11 @@ export function MemorySpace({
]);
const sdkGraphSettled = sdkGraph?.scope === sdkRequestedScope;
const visibleSdkGraph =
sdkGraph?.scope === sdkRequestedScope ? sdkGraph.value : null;
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "ready"
? sdkGraph.value
: null;
const sdkGraphFailed =
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "failed";
// A keyword search pages through one server-side result window. The id is
// kept for the query and source it was searched with; a new query or
// source starts without one, so the server searches afresh.
@@ -247,7 +252,10 @@ export function MemorySpace({
// look identical to Cursor whenever newer Cursor memories occupied
// that prefix.
query
? Promise.resolve(null)
? Promise.resolve({
status: "ready" as const,
value: null,
})
: getSdkMemoryGraph(
{
source,
@@ -255,7 +263,10 @@ export function MemorySpace({
limit: 300,
},
request.signal,
).catch(() => null),
).then(
(value) => ({ status: "ready" as const, value }),
() => ({ status: "failed" as const, value: null }),
),
]);
if (cancelled) return;
if (sdkSearchWindow.current.key === windowKey)
@@ -272,7 +283,7 @@ export function MemorySpace({
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
});
setSdkGraph({ scope: windowKey, value: graph });
setSdkGraph({ scope: windowKey, ...graph });
} catch {
if (cancelled || request.signal.aborted) return;
setSdkLibrary((current) => ({
@@ -1033,6 +1044,7 @@ export function MemorySpace({
page: sdkLibrary.page,
loading: sdkLibrary.loading,
failed: sdkLibrary.failed,
graphFailed: sdkGraphFailed,
stale: !sdkGraphSettled,
unattributedSource: UNATTRIBUTED_SOURCE_ID,
onQuery: setSdkLibraryQuery,
@@ -10,6 +10,7 @@ export const applicationsChineseMessages = {
"apps.readingCount": "正在核对本机记忆…",
"apps.startingRetry": "后台正在启动,稍后自动更新",
"apps.countWillUpdate": "记忆数量稍后自动更新",
"apps.showingPrevious": "正在显示上次数据,稍后自动更新",
"apps.unreadable": "暂无法读取",
"apps.unit": "条",
"apps.scope": "按应用授权范围使用",
@@ -130,6 +131,8 @@ export const applicationsSpanishMessages: Record<
"apps.startingRetry":
"El servicio se está iniciando; se actualizará automáticamente",
"apps.countWillUpdate": "El total de memorias se actualizará automáticamente",
"apps.showingPrevious":
"Se muestran los datos anteriores; se actualizarán automáticamente",
"apps.unreadable": "No disponible",
"apps.unit": "",
"apps.scope": "Según los permisos de cada aplicación",
@@ -1309,6 +1309,8 @@ const baseEnglishMessages = {
"apps.startingRetry":
"The background service is starting; this will update automatically",
"apps.countWillUpdate": "The memory count will update automatically",
"apps.showingPrevious":
"Showing the previous data; this will update automatically",
"apps.unreadable": "Unavailable",
"apps.unit": "items",
"apps.scope": "Used within app permissions",