mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(memory-wiki): preserve notes after transient page reads (#98360)
This commit is contained in:
@@ -39,6 +39,14 @@ function assertUtf8Text(buffer: Buffer, sourcePath: string): string {
|
||||
return buffer.toString("utf8");
|
||||
}
|
||||
|
||||
async function readExistingSourcePage(pagePath: string): Promise<string> {
|
||||
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,
|
||||
|
||||
@@ -11,6 +11,15 @@ import {
|
||||
import { writeGuardedVaultPage } from "./vault-page-write.js";
|
||||
|
||||
type ImportedSourceState = Parameters<typeof shouldSkipImportedSourceWrite>[0]["state"];
|
||||
type VaultRoot = Awaited<ReturnType<typeof fsRoot>>;
|
||||
|
||||
async function readExistingImportedSourcePage(vault: VaultRoot, pagePath: string): Promise<string> {
|
||||
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({
|
||||
|
||||
@@ -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<typeof import("openclaw/plugin-sdk/security-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
root: async (...args: Parameters<typeof actual.root>) => {
|
||||
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",
|
||||
"<!-- openclaw:human:start -->",
|
||||
"<!-- openclaw:human:end -->",
|
||||
"",
|
||||
].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(
|
||||
"<!-- openclaw:human:start -->\n<!-- openclaw:human:end -->",
|
||||
`<!-- openclaw:human:start -->\n${userNote}\n<!-- openclaw:human:end -->`,
|
||||
);
|
||||
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<typeof fs.readFile>): ReturnType<typeof fs.readFile> => {
|
||||
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<typeof writeImportedSourcePage>[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(
|
||||
"<!-- openclaw:human:start -->\n<!-- openclaw:human:end -->",
|
||||
`<!-- openclaw:human:start -->\n${userNote}\n<!-- openclaw:human:end -->`,
|
||||
);
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user