mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-08 02:52:15 +00:00
fix(memory): keep ignored-name QMD roots watchable
Fix QMD watcher ignore handling for explicitly configured roots whose directory names are normally ignored, and prefer the most-specific configured watch root for overlapping collections. Validated with focused QMD/tooling tests, full core support boundary tests, green CI, and ClawSweeper re-review.
This commit is contained in:
@@ -123,6 +123,14 @@ function firstWatchOptions(): WatchOptions {
|
||||
return call[1];
|
||||
}
|
||||
|
||||
function firstWatchPaths(): string[] {
|
||||
const call = watchMock.mock.calls[0] as unknown as [string[], WatchOptions] | undefined;
|
||||
if (!call) {
|
||||
throw new Error("Expected watch call");
|
||||
}
|
||||
return call[0];
|
||||
}
|
||||
|
||||
function firstEmbedLockCall(): EmbedLockCall {
|
||||
const call = withFileLockMock.mock.calls.find((entry) =>
|
||||
entry[0].endsWith(path.join("qmd", "embed.lock")),
|
||||
@@ -579,6 +587,92 @@ describe("QmdMemoryManager", () => {
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("keeps explicit qmd collection roots watchable when their directory name is ignored", async () => {
|
||||
const rootNames = ["build", "dist", "vendor", ".cache"];
|
||||
const roots = rootNames.map((name) => path.join(workspaceDir, name));
|
||||
cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
model: "mock-embed",
|
||||
store: { path: path.join(workspaceDir, "index.sqlite"), vector: { enabled: false } },
|
||||
sync: { watch: true, watchDebounceMs: 25, onSessionStart: false, onSearch: false },
|
||||
},
|
||||
},
|
||||
list: [{ id: agentId, default: true, workspace: workspaceDir }],
|
||||
},
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
update: { interval: "0s", debounceMs: 0, onBoot: false },
|
||||
paths: roots.map((root) => ({
|
||||
path: root,
|
||||
pattern: "**/*.md",
|
||||
name: path.basename(root),
|
||||
})),
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const { manager } = await createManager({ mode: "full" });
|
||||
expect(watchMock).toHaveBeenCalledTimes(1);
|
||||
expect(firstWatchPaths().toSorted()).toEqual(
|
||||
roots.map((root) => path.join(root, "**/*.md")).toSorted(),
|
||||
);
|
||||
const ignored = firstWatchOptions().ignored;
|
||||
for (const root of roots) {
|
||||
expect(ignored?.(root)).toBe(false);
|
||||
expect(ignored?.(path.join(root, "note.md"))).toBe(false);
|
||||
expect(ignored?.(path.join(root, "..notes", "daily.md"))).toBe(false);
|
||||
expect(ignored?.(path.join(root, "notes", "daily.md"))).toBe(false);
|
||||
expect(ignored?.(path.join(root, "node_modules", "pkg", "note.md"))).toBe(true);
|
||||
expect(ignored?.(path.join(root, "build", "artifact.md"))).toBe(true);
|
||||
}
|
||||
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("prefers a nested explicit qmd collection root over a broader watched root", async () => {
|
||||
const nestedRoot = path.join(workspaceDir, "build");
|
||||
cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
model: "mock-embed",
|
||||
store: { path: path.join(workspaceDir, "index.sqlite"), vector: { enabled: false } },
|
||||
sync: { watch: true, watchDebounceMs: 25, onSessionStart: false, onSearch: false },
|
||||
},
|
||||
},
|
||||
list: [{ id: agentId, default: true, workspace: workspaceDir }],
|
||||
},
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
update: { interval: "0s", debounceMs: 0, onBoot: false },
|
||||
paths: [
|
||||
{ path: workspaceDir, pattern: "**/*.md", name: "workspace" },
|
||||
{ path: nestedRoot, pattern: "**/*.md", name: "build" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const { manager } = await createManager({ mode: "full" });
|
||||
const ignored = firstWatchOptions().ignored;
|
||||
expect(ignored?.(path.join(nestedRoot, "note.md"))).toBe(false);
|
||||
expect(ignored?.(path.join(nestedRoot, "..notes", "daily.md"))).toBe(false);
|
||||
expect(ignored?.(path.join(nestedRoot, "node_modules", "pkg", "note.md"))).toBe(true);
|
||||
expect(ignored?.(path.join(workspaceDir, "node_modules", "pkg", "note.md"))).toBe(true);
|
||||
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("delays qmd watch sync until changed file stats settle", async () => {
|
||||
vi.useFakeTimers();
|
||||
cfg = {
|
||||
|
||||
@@ -232,14 +232,37 @@ function resolveQmdStoreWriteLockOptions(updateTimeoutMs: number, embedTimeoutMs
|
||||
);
|
||||
}
|
||||
|
||||
function shouldIgnoreMemoryWatchPath(watchPath: string): boolean {
|
||||
const normalized = path.normalize(watchPath);
|
||||
const parts = normalized
|
||||
function hasIgnoredMemoryWatchSegment(relativePath: string): boolean {
|
||||
const parts = relativePath
|
||||
.split(path.sep)
|
||||
.map((segment) => normalizeLowercaseStringOrEmpty(segment));
|
||||
.map((segment) => normalizeLowercaseStringOrEmpty(segment))
|
||||
.filter(Boolean);
|
||||
return parts.some((segment) => IGNORED_MEMORY_WATCH_DIR_NAMES.has(segment));
|
||||
}
|
||||
|
||||
function shouldIgnoreMemoryWatchPath(watchPath: string, roots: readonly string[]): boolean {
|
||||
const normalized = path.normalize(watchPath);
|
||||
let matchedRelative: string | null = null;
|
||||
let matchedRootLength = -1;
|
||||
for (const watchRoot of roots) {
|
||||
const normalizedRoot = path.normalize(watchRoot);
|
||||
const relative = path.relative(normalizedRoot, normalized);
|
||||
if (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
|
||||
if (normalizedRoot.length > matchedRootLength) {
|
||||
matchedRelative = relative;
|
||||
matchedRootLength = normalizedRoot.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matchedRelative !== null) {
|
||||
if (matchedRelative === "") {
|
||||
return false;
|
||||
}
|
||||
return hasIgnoredMemoryWatchSegment(matchedRelative);
|
||||
}
|
||||
return hasIgnoredMemoryWatchSegment(normalized);
|
||||
}
|
||||
|
||||
type CollectionRoot = {
|
||||
path: string;
|
||||
kind: MemorySource;
|
||||
@@ -1653,10 +1676,12 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
return;
|
||||
}
|
||||
const watchPaths = new Set<string>();
|
||||
const watchRoots = new Set<string>();
|
||||
for (const collection of this.qmd.collections) {
|
||||
if (collection.kind === "sessions") {
|
||||
continue;
|
||||
}
|
||||
watchRoots.add(path.normalize(collection.path));
|
||||
watchPaths.add(this.resolveCollectionWatchPath(collection));
|
||||
}
|
||||
if (watchPaths.size === 0) {
|
||||
@@ -1665,9 +1690,10 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
const watchPathList = Array.from(watchPaths);
|
||||
const startTime = Date.now();
|
||||
log.info(`qmd watcher starting for agent "${this.agentId}" paths=${watchPathList.length}`);
|
||||
const watchRootList = Array.from(watchRoots);
|
||||
const watcher = chokidar.watch(watchPathList, {
|
||||
ignoreInitial: true,
|
||||
ignored: (watchPath) => shouldIgnoreMemoryWatchPath(watchPath),
|
||||
ignored: (watchPath) => shouldIgnoreMemoryWatchPath(watchPath, watchRootList),
|
||||
});
|
||||
this.watcher = watcher;
|
||||
const markDirty = (watchPath?: string, stats?: MemoryWatchEventStats) => {
|
||||
|
||||
@@ -198,6 +198,7 @@ src/a.ts: src/a.ts
|
||||
const resultPromise = runKnipUnusedFiles({
|
||||
env: { PATH: "" },
|
||||
npmExecPath: "",
|
||||
platform: "linux",
|
||||
spawnCommand(command: string, args: string[], options: unknown) {
|
||||
calls.push({ args, command, options });
|
||||
const child = new FakeKnipProcess();
|
||||
|
||||
Reference in New Issue
Block a user