fix(memory-wiki): preserve unavailable source pages (#111034)

Preserve imported unsafe-local wiki pages and human annotations while configured source scopes are temporarily unreadable.
This commit is contained in:
Peter Steinberger
2026-07-19 00:39:23 +01:00
committed by GitHub
parent d411559dfb
commit 941ed4fd4a
2 changed files with 121 additions and 27 deletions
@@ -74,7 +74,7 @@ describe("syncMemoryWikiUnsafeLocalSources", () => {
expect(second.removedCount).toBe(0);
});
it("prunes stale unsafe-local pages when configured files disappear", async () => {
it("prunes stale unsafe-local pages from an available configured directory", async () => {
const privateDir = await createPrivateDir("private-prune");
const secretPath = path.join(privateDir, "secret.md");
@@ -86,7 +86,7 @@ describe("syncMemoryWikiUnsafeLocalSources", () => {
vaultMode: "unsafe-local",
unsafeLocal: {
allowPrivateMemoryCoreAccess: true,
paths: [secretPath],
paths: [privateDir],
},
},
});
@@ -108,6 +108,70 @@ describe("syncMemoryWikiUnsafeLocalSources", () => {
);
});
it("preserves pages and human notes for unavailable configured paths", async () => {
const unavailableDir = await createPrivateDir("temporarily-unavailable");
const availableDir = await createPrivateDir("still-available");
const unavailableSource = path.join(unavailableDir, "keep.md");
const availableSource = path.join(availableDir, "delete.md");
await fs.writeFile(unavailableSource, "# keep me\n", "utf8");
await fs.writeFile(availableSource, "# delete me\n", "utf8");
const { rootDir: vaultDir, config } = await createVault({
rootDir: nextCaseRoot("unavailable-vault"),
config: {
vaultMode: "unsafe-local",
unsafeLocal: {
allowPrivateMemoryCoreAccess: true,
paths: [unavailableDir, availableDir],
},
},
});
const first = await syncMemoryWikiUnsafeLocalSources(config);
const pageContents = await Promise.all(
first.pagePaths.map(async (pagePath) => ({
pagePath,
content: await fs.readFile(path.join(vaultDir, pagePath), "utf8"),
})),
);
const unavailablePage = pageContents.find((page) => page.content.includes("# keep me"));
const availablePage = pageContents.find((page) => page.content.includes("# delete me"));
expect(unavailablePage).toBeDefined();
expect(availablePage).toBeDefined();
await fs.writeFile(
path.join(vaultDir, unavailablePage!.pagePath),
unavailablePage!.content.replace(
"<!-- openclaw:human:start -->\n<!-- openclaw:human:end -->",
"<!-- openclaw:human:start -->\nremember this\n<!-- openclaw:human:end -->",
),
"utf8",
);
const offlinePath = `${unavailableDir}.offline`;
await fs.rename(unavailableDir, offlinePath);
await fs.rm(availableSource);
const duringOutage = await syncMemoryWikiUnsafeLocalSources(config);
expect(duringOutage.artifactCount).toBe(0);
expect(duringOutage.removedCount).toBe(1);
await expect(
fs.readFile(path.join(vaultDir, unavailablePage!.pagePath), "utf8"),
).resolves.toContain("remember this");
await expect(fs.stat(path.join(vaultDir, availablePage!.pagePath))).rejects.toHaveProperty(
"code",
"ENOENT",
);
await fs.rename(offlinePath, unavailableDir);
const afterRecovery = await syncMemoryWikiUnsafeLocalSources(config);
expect(afterRecovery.skippedCount).toBe(1);
expect(afterRecovery.removedCount).toBe(0);
await expect(
fs.readFile(path.join(vaultDir, unavailablePage!.pagePath), "utf8"),
).resolves.toContain("remember this");
});
it("caps composed unsafe-local filenames to the filesystem component limit", async () => {
const privateDir = await createPrivateDir(`${"漢".repeat(50)}-private`);
const nestedDir = path.join(privateDir, `${"語".repeat(50)}-nested`);
+55 -25
View File
@@ -30,6 +30,11 @@ type UnsafeLocalArtifact = {
relativePath: string;
};
type UnsafeLocalArtifactCollection = {
artifacts: UnsafeLocalArtifact[];
unavailableConfiguredPaths: string[];
};
const DIRECTORY_TEXT_EXTENSIONS = new Set([".json", ".jsonl", ".md", ".txt", ".yaml", ".yml"]);
const UNSAFE_LOCAL_SYNC_CONCURRENCY = 16;
@@ -48,7 +53,7 @@ function detectFenceLanguage(filePath: string): string {
}
async function listAllowedFilesRecursive(rootDir: string): Promise<string[]> {
const entries = await fs.readdir(rootDir, { withFileTypes: true }).catch(() => []);
const entries = await fs.readdir(rootDir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const fullPath = path.join(rootDir, entry.name);
@@ -68,41 +73,52 @@ async function listAllowedFilesRecursive(rootDir: string): Promise<string[]> {
async function collectUnsafeLocalArtifacts(
configuredPaths: string[],
): Promise<UnsafeLocalArtifact[]> {
): Promise<UnsafeLocalArtifactCollection> {
const artifacts: UnsafeLocalArtifact[] = [];
const unavailableConfiguredPaths: string[] = [];
for (const configuredPath of configuredPaths) {
const absoluteConfiguredPath = path.resolve(configuredPath);
const stat = await fs.stat(absoluteConfiguredPath).catch(() => null);
if (!stat) {
continue;
}
if (stat.isDirectory()) {
const files = await listAllowedFilesRecursive(absoluteConfiguredPath);
for (const absolutePath of files) {
artifacts.push({
syncKey: await resolveArtifactKey(absolutePath),
const scopedArtifacts: UnsafeLocalArtifact[] = [];
try {
const stat = await fs.stat(absoluteConfiguredPath);
if (stat.isDirectory()) {
const files = await listAllowedFilesRecursive(absoluteConfiguredPath);
for (const absolutePath of files) {
scopedArtifacts.push({
syncKey: await resolveArtifactKey(absolutePath),
configuredPath: absoluteConfiguredPath,
absolutePath,
relativePath: path.relative(absoluteConfiguredPath, absolutePath).replace(/\\/g, "/"),
});
}
} else if (stat.isFile()) {
scopedArtifacts.push({
syncKey: await resolveArtifactKey(absoluteConfiguredPath),
configuredPath: absoluteConfiguredPath,
absolutePath,
relativePath: path.relative(absoluteConfiguredPath, absolutePath).replace(/\\/g, "/"),
absolutePath: absoluteConfiguredPath,
relativePath: path.basename(absoluteConfiguredPath),
});
}
} catch {
unavailableConfiguredPaths.push(absoluteConfiguredPath);
continue;
}
if (stat.isFile()) {
artifacts.push({
syncKey: await resolveArtifactKey(absoluteConfiguredPath),
configuredPath: absoluteConfiguredPath,
absolutePath: absoluteConfiguredPath,
relativePath: path.basename(absoluteConfiguredPath),
});
}
artifacts.push(...scopedArtifacts);
}
const deduped = new Map<string, UnsafeLocalArtifact>();
for (const artifact of artifacts) {
deduped.set(artifact.syncKey, artifact);
}
return [...deduped.values()];
return { artifacts: [...deduped.values()], unavailableConfiguredPaths };
}
function isSourceWithinConfiguredPath(sourcePath: string, configuredPath: string): boolean {
const relative = path.relative(configuredPath, sourcePath);
return (
relative === "" ||
(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
);
}
function resolveUnsafeLocalPagePath(params: { configuredPath: string; absolutePath: string }): {
@@ -216,14 +232,28 @@ export async function syncMemoryWikiUnsafeLocalSources(
};
}
const artifacts = await collectUnsafeLocalArtifacts(config.unsafeLocal.paths);
const { artifacts, unavailableConfiguredPaths } = await collectUnsafeLocalArtifacts(
config.unsafeLocal.paths,
);
const state = await readMemoryWikiSourceSyncState(config.vault.path);
const activeKeys = new Set<string>();
for (const [syncKey, entry] of Object.entries(state.entries)) {
if (
entry.group === "unsafe-local" &&
unavailableConfiguredPaths.some((configuredPath) =>
isSourceWithinConfiguredPath(entry.sourcePath, configuredPath),
)
) {
// A configured source scope remains authoritative until it is readable again or removed
// from config. Treating an unreadable mount as empty would permanently delete human notes.
activeKeys.add(syncKey);
}
}
assertMemoryWikiSourceSyncStateCapacity({
state,
group: "unsafe-local",
incomingCount: artifacts.length,
incomingCount: new Set([...artifacts.map((artifact) => artifact.syncKey), ...activeKeys]).size,
});
const activeKeys = new Set<string>();
const results = await pMap(
artifacts,
async (artifact) => {