Skip to content

Commit ea1263a

Browse files
committed
fix(memory-wiki): preserve fs-safe policy failures
1 parent 7f54997 commit ea1263a

3 files changed

Lines changed: 132 additions & 55 deletions

File tree

extensions/memory-wiki/src/apply.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -194,18 +194,13 @@ function buildSynthesisBody(params: {
194194
type VaultRoot = Awaited<ReturnType<typeof fsRoot>>;
195195

196196
function isMissingWikiPageError(error: unknown): boolean {
197-
return (
198-
error instanceof FsSafeError && (error.code === "not-found" || error.code === "path-alias")
199-
);
197+
return error instanceof FsSafeError && error.code === "not-found";
200198
}
201199

202200
async function readExistingWikiPage(root: VaultRoot, pagePath: string): Promise<string> {
203201
try {
204202
return await root.readText(pagePath);
205-
} catch (error) {
206-
if (isMissingWikiPageError(error)) {
207-
return "";
208-
}
203+
} catch {
209204
try {
210205
return await root.readText(pagePath);
211206
} catch (retryError) {

extensions/memory-wiki/src/chatgpt-import.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,7 @@ function isMissingConversationPageError(error: unknown): boolean {
143143
async function readExistingConversationPage(absolutePath: string): Promise<string> {
144144
try {
145145
return await fs.readFile(absolutePath, "utf8");
146-
} catch (error) {
147-
if (isMissingConversationPageError(error)) {
148-
return "";
149-
}
146+
} catch {
150147
try {
151148
return await fs.readFile(absolutePath, "utf8");
152149
} catch (retryError) {

extensions/memory-wiki/src/wiki-notes-read-retry.test.ts

Lines changed: 129 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
4+
import { FsSafeError } from "openclaw/plugin-sdk/security-runtime";
45
import { afterEach, describe, expect, it, vi } from "vitest";
56
import { applyMemoryWikiMutation } from "./apply.js";
67
import { importChatGptConversations } from "./chatgpt-import.js";
@@ -11,6 +12,9 @@ import { createMemoryWikiTestHarness } from "./test-helpers.js";
1112

1213
const securityRuntimeMock = vi.hoisted(() => ({
1314
failReadTextOnceFor: undefined as string | undefined,
15+
failReadTextAlwaysFor: undefined as string | undefined,
16+
readTextOnceError: new Error("transient existing-page read failure"),
17+
readTextError: new Error("persistent existing-page read failure"),
1418
readTextFailureInjected: false,
1519
}));
1620

@@ -26,12 +30,16 @@ vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => {
2630
return Reflect.get(target, prop, receiver);
2731
}
2832
return async (relativePath: string) => {
33+
if (securityRuntimeMock.failReadTextAlwaysFor === relativePath) {
34+
securityRuntimeMock.readTextFailureInjected = true;
35+
throw securityRuntimeMock.readTextError;
36+
}
2937
if (
3038
securityRuntimeMock.failReadTextOnceFor === relativePath &&
3139
!securityRuntimeMock.readTextFailureInjected
3240
) {
3341
securityRuntimeMock.readTextFailureInjected = true;
34-
throw new Error("transient existing-page read failure");
42+
throw securityRuntimeMock.readTextOnceError;
3543
}
3644
return target.readText(relativePath);
3745
};
@@ -67,10 +75,63 @@ function buildSourcePage(raw: string, updatedAt: string): string {
6775
});
6876
}
6977

78+
async function createChatGptImportFixture(prefix: string) {
79+
const { rootDir, config } = await createVault({ prefix });
80+
const exportDir = path.join(rootDir, "chatgpt-export");
81+
await fs.mkdir(exportDir, { recursive: true });
82+
await fs.writeFile(
83+
path.join(exportDir, "conversations.json"),
84+
`${JSON.stringify([
85+
{
86+
conversation_id: "12345678-1234-1234-1234-1234567890ab",
87+
title: "Travel preference check",
88+
create_time: 1_712_363_200,
89+
update_time: 1_712_366_800,
90+
current_node: "assistant-1",
91+
mapping: {
92+
root: {},
93+
"user-1": {
94+
parent: "root",
95+
message: {
96+
author: { role: "user" },
97+
content: { parts: ["I prefer aisle seats."] },
98+
},
99+
},
100+
"assistant-1": {
101+
parent: "user-1",
102+
message: {
103+
author: { role: "assistant" },
104+
content: { parts: ["Noted."] },
105+
},
106+
},
107+
},
108+
},
109+
])}\n`,
110+
"utf8",
111+
);
112+
await importChatGptConversations({
113+
config,
114+
exportPath: exportDir,
115+
nowMs: Date.UTC(2026, 3, 5, 12, 0, 0),
116+
});
117+
const sourceFiles = (await fs.readdir(path.join(rootDir, "sources"))).filter(
118+
(entry) => entry !== "index.md",
119+
);
120+
expect(sourceFiles).toHaveLength(1);
121+
return {
122+
config,
123+
exportDir,
124+
pagePath: path.join(rootDir, "sources", sourceFiles[0]),
125+
};
126+
}
127+
70128
describe("memory-wiki existing-page read retry", () => {
71129
afterEach(() => {
72130
vi.restoreAllMocks();
73131
securityRuntimeMock.failReadTextOnceFor = undefined;
132+
securityRuntimeMock.failReadTextAlwaysFor = undefined;
133+
securityRuntimeMock.readTextOnceError = new Error("transient existing-page read failure");
134+
securityRuntimeMock.readTextError = new Error("persistent existing-page read failure");
74135
securityRuntimeMock.readTextFailureInjected = false;
75136
});
76137

@@ -200,6 +261,10 @@ describe("memory-wiki existing-page read retry", () => {
200261
await fs.writeFile(pagePath, edited, "utf8");
201262

202263
securityRuntimeMock.failReadTextOnceFor = "syntheses/release-plan.md";
264+
securityRuntimeMock.readTextOnceError = new FsSafeError(
265+
"not-found",
266+
"page temporarily missing",
267+
);
203268

204269
await applyMemoryWikiMutation({
205270
config,
@@ -218,53 +283,47 @@ describe("memory-wiki existing-page read retry", () => {
218283
expect(after).toContain("privacyTier: sensitive");
219284
});
220285

221-
it("preserves chatgpt conversation notes after a transient existing-page read failure", async () => {
222-
const { rootDir, config } = await createVault({ prefix: "memory-wiki-chatgpt-read-retry-" });
223-
const exportDir = path.join(rootDir, "chatgpt-export");
224-
await fs.mkdir(exportDir, { recursive: true });
225-
const conversations = [
226-
{
227-
conversation_id: "12345678-1234-1234-1234-1234567890ab",
228-
title: "Travel preference check",
229-
create_time: 1_712_363_200,
230-
update_time: 1_712_366_800,
231-
current_node: "assistant-1",
232-
mapping: {
233-
root: {},
234-
"user-1": {
235-
parent: "root",
236-
message: {
237-
author: { role: "user" },
238-
content: { parts: ["I prefer aisle seats."] },
239-
},
240-
},
241-
"assistant-1": {
242-
parent: "user-1",
243-
message: {
244-
author: { role: "assistant" },
245-
content: { parts: ["Noted."] },
246-
},
247-
},
248-
},
249-
},
250-
];
251-
await fs.writeFile(
252-
path.join(exportDir, "conversations.json"),
253-
`${JSON.stringify(conversations, null, 2)}\n`,
254-
"utf8",
255-
);
286+
it("does not treat a path-alias policy failure as a missing synthesis page", async () => {
287+
const { rootDir, config } = await createVault({ prefix: "memory-wiki-apply-path-alias-" });
256288

257-
await importChatGptConversations({
289+
await applyMemoryWikiMutation({
258290
config,
259-
exportPath: exportDir,
260-
nowMs: Date.UTC(2026, 3, 5, 12, 0, 0),
291+
mutation: {
292+
op: "create_synthesis",
293+
title: "Release Plan",
294+
body: "Initial summary.",
295+
sourceIds: ["source.alpha"],
296+
},
261297
});
262298

263-
const sourceFiles = (await fs.readdir(path.join(rootDir, "sources"))).filter(
264-
(entry) => entry !== "index.md",
299+
const pagePath = path.join(rootDir, "syntheses", "release-plan.md");
300+
const before = await fs.readFile(pagePath, "utf8");
301+
securityRuntimeMock.failReadTextAlwaysFor = "syntheses/release-plan.md";
302+
securityRuntimeMock.readTextError = new FsSafeError(
303+
"path-alias",
304+
"page resolved outside the vault root",
305+
);
306+
307+
await expect(
308+
applyMemoryWikiMutation({
309+
config,
310+
mutation: {
311+
op: "create_synthesis",
312+
title: "Release Plan",
313+
body: "Replacement summary.",
314+
sourceIds: ["source.alpha"],
315+
},
316+
}),
317+
).rejects.toMatchObject({ code: "path-alias" });
318+
319+
expect(securityRuntimeMock.readTextFailureInjected).toBe(true);
320+
await expect(fs.readFile(pagePath, "utf8")).resolves.toBe(before);
321+
});
322+
323+
it("preserves chatgpt conversation notes after a transient existing-page read failure", async () => {
324+
const { config, exportDir, pagePath } = await createChatGptImportFixture(
325+
"memory-wiki-chatgpt-read-retry-",
265326
);
266-
expect(sourceFiles).toHaveLength(1);
267-
const pagePath = path.join(rootDir, "sources", sourceFiles[0]);
268327
const userNote = "HUMAN NOTE: verified against the airline booking.";
269328
const edited = (await fs.readFile(pagePath, "utf8")).replace(
270329
"<!-- openclaw:human:start -->\n<!-- openclaw:human:end -->",
@@ -278,7 +337,7 @@ describe("memory-wiki existing-page read retry", () => {
278337
async (...args: Parameters<typeof fs.readFile>): ReturnType<typeof fs.readFile> => {
279338
if (!injectedFailure && args[0] === pagePath && args[1] === "utf8") {
280339
injectedFailure = true;
281-
throw new Error("transient existing-page read failure");
340+
throw Object.assign(new Error("page temporarily missing"), { code: "ENOENT" });
282341
}
283342
return originalReadFile(...args);
284343
},
@@ -295,4 +354,30 @@ describe("memory-wiki existing-page read retry", () => {
295354
expect(second.createdCount).toBe(0);
296355
expect(after).toContain(userNote);
297356
});
357+
358+
it("leaves a ChatGPT page unchanged after a persistent existing-page read failure", async () => {
359+
const { config, exportDir, pagePath } = await createChatGptImportFixture(
360+
"memory-wiki-chatgpt-persistent-read-",
361+
);
362+
const before = await fs.readFile(pagePath, "utf8");
363+
const originalReadFile = fs.readFile.bind(fs);
364+
vi.spyOn(fs, "readFile").mockImplementation(
365+
async (...args: Parameters<typeof fs.readFile>): ReturnType<typeof fs.readFile> => {
366+
if (args[0] === pagePath && args[1] === "utf8") {
367+
throw Object.assign(new Error("resource busy"), { code: "EBUSY" });
368+
}
369+
return originalReadFile(...args);
370+
},
371+
);
372+
373+
await expect(
374+
importChatGptConversations({
375+
config,
376+
exportPath: exportDir,
377+
nowMs: Date.UTC(2026, 3, 6, 12, 0, 0),
378+
}),
379+
).rejects.toMatchObject({ code: "EBUSY" });
380+
381+
await expect(originalReadFile(pagePath, "utf8")).resolves.toBe(before);
382+
});
298383
});

0 commit comments

Comments
 (0)