mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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:
@@ -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`);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user