fix(memhub): harden memory overview recovery
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
082aeee1f2
commit
5103492762
10 files changed
+242
-13
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user