fix(memory): watch for external file changes and auto-reindex
Previously, the gateway startup only initialized memory managers for the qmd backend, skipping the default builtin SQLite provider. This meant the filesystem watcher was never set up for external file changes. Now both builtin and qmd backends initialize their memory managers at gateway startup, enabling the chokidar watcher to detect external file modifications and trigger automatic reindexing with debouncing. Fixes #45818
This commit is contained in:
parent
c08f2aa21a
commit
0e18a7922b
@ -18,6 +18,13 @@ function createQmdConfig(agents: OpenClawConfig["agents"]): OpenClawConfig {
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function createBuiltinConfig(agents: OpenClawConfig["agents"]): OpenClawConfig {
|
||||
return {
|
||||
agents,
|
||||
memory: { backend: "builtin" },
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function createGatewayLogMock() {
|
||||
return { info: vi.fn(), warn: vi.fn() };
|
||||
}
|
||||
@ -27,17 +34,18 @@ describe("startGatewayMemoryBackend", () => {
|
||||
getMemorySearchManagerMock.mockClear();
|
||||
});
|
||||
|
||||
it("skips initialization when memory backend is not qmd", async () => {
|
||||
const cfg = {
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
memory: { backend: "builtin" },
|
||||
} as OpenClawConfig;
|
||||
const log = { info: vi.fn(), warn: vi.fn() };
|
||||
it("initializes builtin backend for each configured agent", async () => {
|
||||
const cfg = createBuiltinConfig({ list: [{ id: "main", default: true }] });
|
||||
const log = createGatewayLogMock();
|
||||
getMemorySearchManagerMock.mockResolvedValue({ manager: { search: vi.fn() } });
|
||||
|
||||
await startGatewayMemoryBackend({ cfg, log });
|
||||
|
||||
expect(getMemorySearchManagerMock).not.toHaveBeenCalled();
|
||||
expect(log.info).not.toHaveBeenCalled();
|
||||
expect(getMemorySearchManagerMock).toHaveBeenCalledTimes(1);
|
||||
expect(getMemorySearchManagerMock).toHaveBeenCalledWith({ cfg, agentId: "main" });
|
||||
expect(log.info).toHaveBeenCalledWith(
|
||||
'builtin memory startup initialization armed for agent "main"',
|
||||
);
|
||||
expect(log.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@ -62,7 +70,7 @@ describe("startGatewayMemoryBackend", () => {
|
||||
expect(log.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs a warning when qmd manager init fails and continues with other agents", async () => {
|
||||
it("logs a warning when manager init fails and continues with other agents", async () => {
|
||||
const cfg = createQmdConfig({ list: [{ id: "main", default: true }, { id: "ops" }] });
|
||||
const log = createGatewayLogMock();
|
||||
getMemorySearchManagerMock
|
||||
@ -79,6 +87,19 @@ describe("startGatewayMemoryBackend", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("logs a warning when builtin manager init fails", async () => {
|
||||
const cfg = createBuiltinConfig({ list: [{ id: "main", default: true }] });
|
||||
const log = createGatewayLogMock();
|
||||
getMemorySearchManagerMock.mockResolvedValue({ manager: null, error: "sqlite error" });
|
||||
|
||||
await startGatewayMemoryBackend({ cfg, log });
|
||||
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
'builtin memory startup initialization failed for agent "main": sqlite error',
|
||||
);
|
||||
expect(log.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips agents with memory search disabled", async () => {
|
||||
const cfg = createQmdConfig({
|
||||
defaults: { memorySearch: { enabled: true } },
|
||||
|
||||
@ -14,17 +14,15 @@ export async function startGatewayMemoryBackend(params: {
|
||||
continue;
|
||||
}
|
||||
const resolved = resolveMemoryBackendConfig({ cfg: params.cfg, agentId });
|
||||
if (resolved.backend !== "qmd" || !resolved.qmd) {
|
||||
continue;
|
||||
}
|
||||
const backendLabel = resolved.backend === "qmd" ? "qmd" : "builtin";
|
||||
|
||||
const { manager, error } = await getMemorySearchManager({ cfg: params.cfg, agentId });
|
||||
if (!manager) {
|
||||
params.log.warn(
|
||||
`qmd memory startup initialization failed for agent "${agentId}": ${error ?? "unknown error"}`,
|
||||
`${backendLabel} memory startup initialization failed for agent "${agentId}": ${error ?? "unknown error"}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
params.log.info?.(`qmd memory startup initialization armed for agent "${agentId}"`);
|
||||
params.log.info?.(`${backendLabel} memory startup initialization armed for agent "${agentId}"`);
|
||||
}
|
||||
}
|
||||
|
||||
178
src/memory/manager.external-file-watch.test.ts
Normal file
178
src/memory/manager.external-file-watch.test.ts
Normal file
@ -0,0 +1,178 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resetEmbeddingMocks } from "./embedding.test-mocks.js";
|
||||
import type { MemoryIndexManager } from "./index.js";
|
||||
import { getRequiredMemoryIndexManager } from "./test-manager-helpers.js";
|
||||
|
||||
describe("memory manager external file watch", () => {
|
||||
let workspaceDir: string;
|
||||
let indexPath: string;
|
||||
let manager: MemoryIndexManager | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
resetEmbeddingMocks();
|
||||
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-watch-"));
|
||||
indexPath = path.join(workspaceDir, "index.sqlite");
|
||||
await fs.mkdir(path.join(workspaceDir, "memory"));
|
||||
await fs.writeFile(path.join(workspaceDir, "memory", "notes.md"), "initial content");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
if (manager) {
|
||||
await manager.close();
|
||||
manager = null;
|
||||
}
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("triggers sync when external file changes are detected", async () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
model: "mock-embed",
|
||||
store: { path: indexPath, vector: { enabled: false } },
|
||||
sync: { watch: true, watchDebounceMs: 50, onSessionStart: false, onSearch: false },
|
||||
query: { minScore: 0, hybrid: { enabled: false } },
|
||||
},
|
||||
},
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
manager = await getRequiredMemoryIndexManager({ cfg, agentId: "main" });
|
||||
const syncSpy = vi.spyOn(manager, "sync");
|
||||
|
||||
// Initial sync to index the file
|
||||
await manager.sync({ reason: "initial" });
|
||||
syncSpy.mockClear();
|
||||
|
||||
const initialStatus = manager.status();
|
||||
expect(initialStatus.files).toBe(1);
|
||||
expect(initialStatus.chunks).toBeGreaterThan(0);
|
||||
|
||||
// Simulate external file change by calling internal watcher trigger
|
||||
// This mimics what chokidar does when it detects a file change
|
||||
const internalManager = manager as unknown as {
|
||||
dirty: boolean;
|
||||
scheduleWatchSync: () => void;
|
||||
};
|
||||
internalManager.dirty = true;
|
||||
internalManager.scheduleWatchSync();
|
||||
|
||||
// Run the debounce timer
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
// Verify sync was called
|
||||
expect(syncSpy).toHaveBeenCalledTimes(1);
|
||||
expect(syncSpy).toHaveBeenCalledWith({ reason: "watch" });
|
||||
});
|
||||
|
||||
it("debounces multiple rapid file changes", async () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
model: "mock-embed",
|
||||
store: { path: indexPath, vector: { enabled: false } },
|
||||
sync: { watch: true, watchDebounceMs: 100, onSessionStart: false, onSearch: false },
|
||||
query: { minScore: 0, hybrid: { enabled: false } },
|
||||
},
|
||||
},
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
manager = await getRequiredMemoryIndexManager({ cfg, agentId: "main" });
|
||||
const syncSpy = vi.spyOn(manager, "sync");
|
||||
|
||||
// Initial sync
|
||||
await manager.sync({ reason: "initial" });
|
||||
syncSpy.mockClear();
|
||||
|
||||
const internalManager = manager as unknown as {
|
||||
dirty: boolean;
|
||||
scheduleWatchSync: () => void;
|
||||
};
|
||||
|
||||
// Simulate multiple rapid file changes (like a bulk save or editor autosave)
|
||||
internalManager.dirty = true;
|
||||
internalManager.scheduleWatchSync();
|
||||
|
||||
// Advance time partially (less than debounce)
|
||||
await vi.advanceTimersByTimeAsync(30);
|
||||
|
||||
// Another file change comes in
|
||||
internalManager.scheduleWatchSync();
|
||||
|
||||
// Advance time partially again
|
||||
await vi.advanceTimersByTimeAsync(30);
|
||||
|
||||
// Yet another file change
|
||||
internalManager.scheduleWatchSync();
|
||||
|
||||
// Should not have synced yet
|
||||
expect(syncSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Now advance past the debounce time
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Should have synced exactly once
|
||||
expect(syncSpy).toHaveBeenCalledTimes(1);
|
||||
expect(syncSpy).toHaveBeenCalledWith({ reason: "watch" });
|
||||
});
|
||||
|
||||
it("reindexes modified file content after external change", async () => {
|
||||
vi.useRealTimers(); // Need real timers for actual file operations
|
||||
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
model: "mock-embed",
|
||||
store: { path: indexPath, vector: { enabled: false } },
|
||||
sync: { watch: true, watchDebounceMs: 50, onSessionStart: false, onSearch: false },
|
||||
query: { minScore: 0, hybrid: { enabled: false } },
|
||||
},
|
||||
},
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
manager = await getRequiredMemoryIndexManager({ cfg, agentId: "main" });
|
||||
|
||||
// Initial sync
|
||||
await manager.sync({ reason: "initial" });
|
||||
const initialStatus = manager.status();
|
||||
expect(initialStatus.files).toBe(1);
|
||||
|
||||
// Modify file content externally (adding more content)
|
||||
const filePath = path.join(workspaceDir, "memory", "notes.md");
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
"initial content\n\nAdditional paragraph with more information that will create more chunks.",
|
||||
);
|
||||
|
||||
// Mark dirty and force sync (simulating what the watcher would do)
|
||||
const internalManager = manager as unknown as { dirty: boolean };
|
||||
internalManager.dirty = true;
|
||||
await manager.sync({ reason: "watch", force: true });
|
||||
|
||||
// Verify the content was reindexed
|
||||
const newStatus = manager.status();
|
||||
expect(newStatus.files).toBe(1);
|
||||
// Content should have been reprocessed (chunks may vary based on content length)
|
||||
expect(newStatus.dirty).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user