From aa91c9d4a9500d08491944befed4af22ee635be9 Mon Sep 17 00:00:00 2001 From: qingminlong Date: Wed, 1 Jul 2026 17:55:05 +0800 Subject: [PATCH] fix(memory-wiki): preserve notes after transient page reads (#98360) --- extensions/memory-wiki/src/ingest.ts | 10 +- .../memory-wiki/src/source-page-shared.ts | 11 +- .../src/wiki-notes-read-retry.test.ts | 177 ++++++++++++++++++ 3 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 extensions/memory-wiki/src/wiki-notes-read-retry.test.ts diff --git a/extensions/memory-wiki/src/ingest.ts b/extensions/memory-wiki/src/ingest.ts index 398352a5df51..4f9aea5a788e 100644 --- a/extensions/memory-wiki/src/ingest.ts +++ b/extensions/memory-wiki/src/ingest.ts @@ -39,6 +39,14 @@ function assertUtf8Text(buffer: Buffer, sourcePath: string): string { return buffer.toString("utf8"); } +async function readExistingSourcePage(pagePath: string): Promise { + try { + return await fs.readFile(pagePath, "utf8"); + } catch { + return await fs.readFile(pagePath, "utf8"); + } +} + export async function ingestMemoryWikiSource(params: { config: ResolvedMemoryWikiConfig; inputPath: string; @@ -87,7 +95,7 @@ export async function ingestMemoryWikiSource(params: { ].join("\n"), }); - const existing = created ? "" : await fs.readFile(pagePath, "utf8").catch(() => ""); + const existing = created ? "" : await readExistingSourcePage(pagePath); await fs.writeFile( pagePath, existing ? preserveHumanNotesBlock(markdown, existing) : markdown, diff --git a/extensions/memory-wiki/src/source-page-shared.ts b/extensions/memory-wiki/src/source-page-shared.ts index ef50571e7646..7f49c0a8aa98 100644 --- a/extensions/memory-wiki/src/source-page-shared.ts +++ b/extensions/memory-wiki/src/source-page-shared.ts @@ -11,6 +11,15 @@ import { import { writeGuardedVaultPage } from "./vault-page-write.js"; type ImportedSourceState = Parameters[0]["state"]; +type VaultRoot = Awaited>; + +async function readExistingImportedSourcePage(vault: VaultRoot, pagePath: string): Promise { + try { + return await vault.readText(pagePath); + } catch { + return await vault.readText(pagePath); + } +} export async function writeImportedSourcePage(params: { vaultRoot: string; @@ -52,7 +61,7 @@ export async function writeImportedSourcePage(params: { const raw = await fs.readFile(params.sourcePath, "utf8"); const rendered = params.buildRendered(raw, updatedAt); - const existing = pageStat ? await vault.readText(params.pagePath).catch(() => "") : ""; + const existing = pageStat ? await readExistingImportedSourcePage(vault, params.pagePath) : ""; const nextRendered = existing ? preserveHumanNotesBlock(rendered, existing) : rendered; if (existing !== nextRendered) { await writeGuardedVaultPage({ diff --git a/extensions/memory-wiki/src/wiki-notes-read-retry.test.ts b/extensions/memory-wiki/src/wiki-notes-read-retry.test.ts new file mode 100644 index 000000000000..b0935d7410ee --- /dev/null +++ b/extensions/memory-wiki/src/wiki-notes-read-retry.test.ts @@ -0,0 +1,177 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ingestMemoryWikiSource } from "./ingest.js"; +import { renderMarkdownFence, renderWikiMarkdown } from "./markdown.js"; +import { writeImportedSourcePage } from "./source-page-shared.js"; +import { createMemoryWikiTestHarness } from "./test-helpers.js"; + +const securityRuntimeMock = vi.hoisted(() => ({ + failReadTextOnceFor: undefined as string | undefined, + readTextFailureInjected: false, +})); + +vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + root: async (...args: Parameters) => { + const vault = await actual.root(...args); + return new Proxy(vault, { + get(target, prop, receiver) { + if (prop !== "readText") { + return Reflect.get(target, prop, receiver); + } + return async (relativePath: string) => { + if ( + securityRuntimeMock.failReadTextOnceFor === relativePath && + !securityRuntimeMock.readTextFailureInjected + ) { + securityRuntimeMock.readTextFailureInjected = true; + throw new Error("transient existing-page read failure"); + } + return target.readText(relativePath); + }; + }, + }); + }, + }; +}); + +const { createTempDir, createVault } = createMemoryWikiTestHarness(); + +function buildSourcePage(raw: string, updatedAt: string): string { + return renderWikiMarkdown({ + frontmatter: { + pageType: "source", + id: "source.imported", + title: "imported", + sourceType: "memory-unsafe-local", + status: "active", + updatedAt, + }, + body: [ + "# imported", + "", + "## Content", + renderMarkdownFence(raw, "text"), + "", + "## Notes", + "", + "", + "", + ].join("\n"), + }); +} + +describe("memory-wiki existing-page read retry", () => { + afterEach(() => { + vi.restoreAllMocks(); + securityRuntimeMock.failReadTextOnceFor = undefined; + securityRuntimeMock.readTextFailureInjected = false; + }); + + it("preserves ingest notes after a transient existing-page read failure", async () => { + const rootDir = await createTempDir("memory-wiki-reingest-read-retry-"); + const inputPath = path.join(rootDir, "roadmap.txt"); + const { config } = await createVault({ rootDir: path.join(rootDir, "vault") }); + + await fs.writeFile(inputPath, "v1 content\n", "utf8"); + await ingestMemoryWikiSource({ + config, + inputPath, + nowMs: Date.UTC(2026, 3, 5, 12, 0, 0), + }); + + const pagePath = path.join(config.vault.path, "sources", "roadmap.md"); + const userNote = "KEY INSIGHT: covers the Q2 roadmap"; + const edited = (await fs.readFile(pagePath, "utf8")).replace( + "\n", + `\n${userNote}\n`, + ); + await fs.writeFile(pagePath, edited, "utf8"); + + await fs.writeFile(inputPath, "v2 content updated\n", "utf8"); + const originalReadFile = fs.readFile.bind(fs); + let injectedFailure = false; + vi.spyOn(fs, "readFile").mockImplementation( + async (...args: Parameters): ReturnType => { + if (!injectedFailure && args[0] === pagePath && args[1] === "utf8") { + injectedFailure = true; + throw new Error("transient existing-page read failure"); + } + return originalReadFile(...args); + }, + ); + + await ingestMemoryWikiSource({ + config, + inputPath, + nowMs: Date.UTC(2026, 3, 6, 12, 0, 0), + }); + + const after = await originalReadFile(pagePath, "utf8"); + expect(injectedFailure).toBe(true); + expect(after).toContain("v2 content updated"); + expect(after).toContain(userNote); + }); + + it("preserves imported notes after a transient existing-page read failure", async () => { + const suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-source-page-")); + const sourcePath = path.join(suiteRoot, "imported-retry.txt"); + const pagePath = "sources/imported-retry.md"; + const absPage = path.join(suiteRoot, pagePath); + const state: Parameters[0]["state"] = { + entries: {}, + version: 1, + }; + + try { + await fs.writeFile(sourcePath, "first body", "utf8"); + await writeImportedSourcePage({ + vaultRoot: suiteRoot, + syncKey: "bridge:imported-retry", + sourcePath, + sourceUpdatedAtMs: Date.UTC(2026, 4, 1), + sourceSize: 10, + renderFingerprint: "fp-1", + pagePath, + group: "bridge", + state, + buildRendered: buildSourcePage, + }); + + const userNote = "IMPORTED PAGE NOTE FROM HUMAN"; + const edited = (await fs.readFile(absPage, "utf8")).replace( + "\n", + `\n${userNote}\n`, + ); + await fs.writeFile(absPage, edited, "utf8"); + + securityRuntimeMock.failReadTextOnceFor = pagePath; + + await fs.writeFile(sourcePath, "second body changed", "utf8"); + const result = await writeImportedSourcePage({ + vaultRoot: suiteRoot, + syncKey: "bridge:imported-retry", + sourcePath, + sourceUpdatedAtMs: Date.UTC(2026, 4, 2), + sourceSize: 19, + renderFingerprint: "fp-2", + pagePath, + group: "bridge", + state, + buildRendered: buildSourcePage, + }); + + const after = await fs.readFile(absPage, "utf8"); + expect(securityRuntimeMock.readTextFailureInjected).toBe(true); + expect(result.changed).toBe(true); + expect(after).toContain("second body changed"); + expect(after).toContain(userNote); + } finally { + await fs.rm(suiteRoot, { recursive: true, force: true }); + } + }); +});