Skip to content

Commit aa91c9d

Browse files
authored
fix(memory-wiki): preserve notes after transient page reads (#98360)
1 parent 54d2fa2 commit aa91c9d

3 files changed

Lines changed: 196 additions & 2 deletions

File tree

extensions/memory-wiki/src/ingest.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ function assertUtf8Text(buffer: Buffer, sourcePath: string): string {
3939
return buffer.toString("utf8");
4040
}
4141

42+
async function readExistingSourcePage(pagePath: string): Promise<string> {
43+
try {
44+
return await fs.readFile(pagePath, "utf8");
45+
} catch {
46+
return await fs.readFile(pagePath, "utf8");
47+
}
48+
}
49+
4250
export async function ingestMemoryWikiSource(params: {
4351
config: ResolvedMemoryWikiConfig;
4452
inputPath: string;
@@ -87,7 +95,7 @@ export async function ingestMemoryWikiSource(params: {
8795
].join("\n"),
8896
});
8997

90-
const existing = created ? "" : await fs.readFile(pagePath, "utf8").catch(() => "");
98+
const existing = created ? "" : await readExistingSourcePage(pagePath);
9199
await fs.writeFile(
92100
pagePath,
93101
existing ? preserveHumanNotesBlock(markdown, existing) : markdown,

extensions/memory-wiki/src/source-page-shared.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ import {
1111
import { writeGuardedVaultPage } from "./vault-page-write.js";
1212

1313
type ImportedSourceState = Parameters<typeof shouldSkipImportedSourceWrite>[0]["state"];
14+
type VaultRoot = Awaited<ReturnType<typeof fsRoot>>;
15+
16+
async function readExistingImportedSourcePage(vault: VaultRoot, pagePath: string): Promise<string> {
17+
try {
18+
return await vault.readText(pagePath);
19+
} catch {
20+
return await vault.readText(pagePath);
21+
}
22+
}
1423

1524
export async function writeImportedSourcePage(params: {
1625
vaultRoot: string;
@@ -52,7 +61,7 @@ export async function writeImportedSourcePage(params: {
5261

5362
const raw = await fs.readFile(params.sourcePath, "utf8");
5463
const rendered = params.buildRendered(raw, updatedAt);
55-
const existing = pageStat ? await vault.readText(params.pagePath).catch(() => "") : "";
64+
const existing = pageStat ? await readExistingImportedSourcePage(vault, params.pagePath) : "";
5665
const nextRendered = existing ? preserveHumanNotesBlock(rendered, existing) : rendered;
5766
if (existing !== nextRendered) {
5867
await writeGuardedVaultPage({
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
5+
import { ingestMemoryWikiSource } from "./ingest.js";
6+
import { renderMarkdownFence, renderWikiMarkdown } from "./markdown.js";
7+
import { writeImportedSourcePage } from "./source-page-shared.js";
8+
import { createMemoryWikiTestHarness } from "./test-helpers.js";
9+
10+
const securityRuntimeMock = vi.hoisted(() => ({
11+
failReadTextOnceFor: undefined as string | undefined,
12+
readTextFailureInjected: false,
13+
}));
14+
15+
vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => {
16+
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/security-runtime")>();
17+
return {
18+
...actual,
19+
root: async (...args: Parameters<typeof actual.root>) => {
20+
const vault = await actual.root(...args);
21+
return new Proxy(vault, {
22+
get(target, prop, receiver) {
23+
if (prop !== "readText") {
24+
return Reflect.get(target, prop, receiver);
25+
}
26+
return async (relativePath: string) => {
27+
if (
28+
securityRuntimeMock.failReadTextOnceFor === relativePath &&
29+
!securityRuntimeMock.readTextFailureInjected
30+
) {
31+
securityRuntimeMock.readTextFailureInjected = true;
32+
throw new Error("transient existing-page read failure");
33+
}
34+
return target.readText(relativePath);
35+
};
36+
},
37+
});
38+
},
39+
};
40+
});
41+
42+
const { createTempDir, createVault } = createMemoryWikiTestHarness();
43+
44+
function buildSourcePage(raw: string, updatedAt: string): string {
45+
return renderWikiMarkdown({
46+
frontmatter: {
47+
pageType: "source",
48+
id: "source.imported",
49+
title: "imported",
50+
sourceType: "memory-unsafe-local",
51+
status: "active",
52+
updatedAt,
53+
},
54+
body: [
55+
"# imported",
56+
"",
57+
"## Content",
58+
renderMarkdownFence(raw, "text"),
59+
"",
60+
"## Notes",
61+
"<!-- openclaw:human:start -->",
62+
"<!-- openclaw:human:end -->",
63+
"",
64+
].join("\n"),
65+
});
66+
}
67+
68+
describe("memory-wiki existing-page read retry", () => {
69+
afterEach(() => {
70+
vi.restoreAllMocks();
71+
securityRuntimeMock.failReadTextOnceFor = undefined;
72+
securityRuntimeMock.readTextFailureInjected = false;
73+
});
74+
75+
it("preserves ingest notes after a transient existing-page read failure", async () => {
76+
const rootDir = await createTempDir("memory-wiki-reingest-read-retry-");
77+
const inputPath = path.join(rootDir, "roadmap.txt");
78+
const { config } = await createVault({ rootDir: path.join(rootDir, "vault") });
79+
80+
await fs.writeFile(inputPath, "v1 content\n", "utf8");
81+
await ingestMemoryWikiSource({
82+
config,
83+
inputPath,
84+
nowMs: Date.UTC(2026, 3, 5, 12, 0, 0),
85+
});
86+
87+
const pagePath = path.join(config.vault.path, "sources", "roadmap.md");
88+
const userNote = "KEY INSIGHT: covers the Q2 roadmap";
89+
const edited = (await fs.readFile(pagePath, "utf8")).replace(
90+
"<!-- openclaw:human:start -->\n<!-- openclaw:human:end -->",
91+
`<!-- openclaw:human:start -->\n${userNote}\n<!-- openclaw:human:end -->`,
92+
);
93+
await fs.writeFile(pagePath, edited, "utf8");
94+
95+
await fs.writeFile(inputPath, "v2 content updated\n", "utf8");
96+
const originalReadFile = fs.readFile.bind(fs);
97+
let injectedFailure = false;
98+
vi.spyOn(fs, "readFile").mockImplementation(
99+
async (...args: Parameters<typeof fs.readFile>): ReturnType<typeof fs.readFile> => {
100+
if (!injectedFailure && args[0] === pagePath && args[1] === "utf8") {
101+
injectedFailure = true;
102+
throw new Error("transient existing-page read failure");
103+
}
104+
return originalReadFile(...args);
105+
},
106+
);
107+
108+
await ingestMemoryWikiSource({
109+
config,
110+
inputPath,
111+
nowMs: Date.UTC(2026, 3, 6, 12, 0, 0),
112+
});
113+
114+
const after = await originalReadFile(pagePath, "utf8");
115+
expect(injectedFailure).toBe(true);
116+
expect(after).toContain("v2 content updated");
117+
expect(after).toContain(userNote);
118+
});
119+
120+
it("preserves imported notes after a transient existing-page read failure", async () => {
121+
const suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-source-page-"));
122+
const sourcePath = path.join(suiteRoot, "imported-retry.txt");
123+
const pagePath = "sources/imported-retry.md";
124+
const absPage = path.join(suiteRoot, pagePath);
125+
const state: Parameters<typeof writeImportedSourcePage>[0]["state"] = {
126+
entries: {},
127+
version: 1,
128+
};
129+
130+
try {
131+
await fs.writeFile(sourcePath, "first body", "utf8");
132+
await writeImportedSourcePage({
133+
vaultRoot: suiteRoot,
134+
syncKey: "bridge:imported-retry",
135+
sourcePath,
136+
sourceUpdatedAtMs: Date.UTC(2026, 4, 1),
137+
sourceSize: 10,
138+
renderFingerprint: "fp-1",
139+
pagePath,
140+
group: "bridge",
141+
state,
142+
buildRendered: buildSourcePage,
143+
});
144+
145+
const userNote = "IMPORTED PAGE NOTE FROM HUMAN";
146+
const edited = (await fs.readFile(absPage, "utf8")).replace(
147+
"<!-- openclaw:human:start -->\n<!-- openclaw:human:end -->",
148+
`<!-- openclaw:human:start -->\n${userNote}\n<!-- openclaw:human:end -->`,
149+
);
150+
await fs.writeFile(absPage, edited, "utf8");
151+
152+
securityRuntimeMock.failReadTextOnceFor = pagePath;
153+
154+
await fs.writeFile(sourcePath, "second body changed", "utf8");
155+
const result = await writeImportedSourcePage({
156+
vaultRoot: suiteRoot,
157+
syncKey: "bridge:imported-retry",
158+
sourcePath,
159+
sourceUpdatedAtMs: Date.UTC(2026, 4, 2),
160+
sourceSize: 19,
161+
renderFingerprint: "fp-2",
162+
pagePath,
163+
group: "bridge",
164+
state,
165+
buildRendered: buildSourcePage,
166+
});
167+
168+
const after = await fs.readFile(absPage, "utf8");
169+
expect(securityRuntimeMock.readTextFailureInjected).toBe(true);
170+
expect(result.changed).toBe(true);
171+
expect(after).toContain("second body changed");
172+
expect(after).toContain(userNote);
173+
} finally {
174+
await fs.rm(suiteRoot, { recursive: true, force: true });
175+
}
176+
});
177+
});

0 commit comments

Comments
 (0)