Memory/QMD: migrate legacy unscoped collections
This commit is contained in:
@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron/Gateway: keep `cron.list` and `cron.status` responsive during startup catch-up by avoiding a long-held cron lock while missed jobs execute. (#23106) Thanks @jayleekr.
|
||||
- Gateway/Config reload: compare array-valued config paths structurally during diffing so unchanged `memory.qmd.paths` and `memory.qmd.scope.rules` no longer trigger false restart-required reloads. (#23185) Thanks @rex05ai.
|
||||
- Cron/Scheduling: validate runtime cron expressions before schedule/stagger evaluation so malformed persisted jobs report a clear `invalid cron schedule: expr is required` error instead of crashing with `undefined.trim` failures and auto-disable churn. (#23223) Thanks @asimons81.
|
||||
- Memory/QMD: migrate legacy unscoped collection bindings (for example `memory-root`) to per-agent scoped names (for example `memory-root-main`) during startup when safe, so QMD-backed `memory_search` no longer fails with `Collection not found` after upgrades. (#23228, #20727) Thanks @JLDynamics and @AaronFaby.
|
||||
- TUI/Input: enable multiline-paste burst coalescing on macOS Terminal.app and iTerm so pasted blocks no longer submit line-by-line as separate messages. (#18809) Thanks @fwends.
|
||||
- TUI/Status: request immediate renders after setting `sending`/`waiting` activity states so in-flight runs always show visible progress indicators instead of appearing idle until completion. (#21549) Thanks @13Guinness.
|
||||
- Agents/Fallbacks: treat JSON payloads with `type: "api_error"` + `"Internal server error"` as transient failover errors so Anthropic 500-style failures trigger model fallback. (#23193) Thanks @jarvis-lane.
|
||||
|
||||
@@ -414,6 +414,132 @@ describe("QmdMemoryManager", () => {
|
||||
expect(addSessions).toBeDefined();
|
||||
});
|
||||
|
||||
it("migrates unscoped legacy collections before adding scoped names", async () => {
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: true,
|
||||
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
|
||||
paths: [],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const legacyCollections = new Map<
|
||||
string,
|
||||
{
|
||||
path: string;
|
||||
mask: string;
|
||||
}
|
||||
>([
|
||||
["memory-root", { path: workspaceDir, mask: "MEMORY.md" }],
|
||||
["memory-alt", { path: workspaceDir, mask: "memory.md" }],
|
||||
["memory-dir", { path: path.join(workspaceDir, "memory"), mask: "**/*.md" }],
|
||||
]);
|
||||
const removeCalls: string[] = [];
|
||||
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === "collection" && args[1] === "list") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(
|
||||
child,
|
||||
"stdout",
|
||||
JSON.stringify(
|
||||
[...legacyCollections.entries()].map(([name, info]) => ({
|
||||
name,
|
||||
path: info.path,
|
||||
mask: info.mask,
|
||||
})),
|
||||
),
|
||||
);
|
||||
return child;
|
||||
}
|
||||
if (args[0] === "collection" && args[1] === "remove") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
const name = args[2] ?? "";
|
||||
removeCalls.push(name);
|
||||
legacyCollections.delete(name);
|
||||
queueMicrotask(() => child.closeWith(0));
|
||||
return child;
|
||||
}
|
||||
if (args[0] === "collection" && args[1] === "add") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
const pathArg = args[2] ?? "";
|
||||
const name = args[args.indexOf("--name") + 1] ?? "";
|
||||
const mask = args[args.indexOf("--mask") + 1] ?? "";
|
||||
const hasConflict = [...legacyCollections.entries()].some(
|
||||
([existingName, info]) =>
|
||||
existingName !== name && info.path === pathArg && info.mask === mask,
|
||||
);
|
||||
if (hasConflict) {
|
||||
emitAndClose(child, "stderr", "collection already exists", 1);
|
||||
return child;
|
||||
}
|
||||
legacyCollections.set(name, { path: pathArg, mask });
|
||||
queueMicrotask(() => child.closeWith(0));
|
||||
return child;
|
||||
}
|
||||
return createMockChild();
|
||||
});
|
||||
|
||||
const { manager } = await createManager({ mode: "full" });
|
||||
await manager.close();
|
||||
|
||||
expect(removeCalls).toEqual(["memory-root", "memory-alt", "memory-dir"]);
|
||||
expect(legacyCollections.has("memory-root-main")).toBe(true);
|
||||
expect(legacyCollections.has("memory-alt-main")).toBe(true);
|
||||
expect(legacyCollections.has("memory-dir-main")).toBe(true);
|
||||
expect(legacyCollections.has("memory-root")).toBe(false);
|
||||
expect(legacyCollections.has("memory-alt")).toBe(false);
|
||||
expect(legacyCollections.has("memory-dir")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not migrate unscoped collections when listed metadata differs", async () => {
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: true,
|
||||
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
|
||||
paths: [],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const differentPath = path.join(tmpRoot, "other-memory");
|
||||
await fs.mkdir(differentPath, { recursive: true });
|
||||
const removeCalls: string[] = [];
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === "collection" && args[1] === "list") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(
|
||||
child,
|
||||
"stdout",
|
||||
JSON.stringify([{ name: "memory-root", path: differentPath, mask: "MEMORY.md" }]),
|
||||
);
|
||||
return child;
|
||||
}
|
||||
if (args[0] === "collection" && args[1] === "remove") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
removeCalls.push(args[2] ?? "");
|
||||
queueMicrotask(() => child.closeWith(0));
|
||||
return child;
|
||||
}
|
||||
return createMockChild();
|
||||
});
|
||||
|
||||
const { manager } = await createManager({ mode: "full" });
|
||||
await manager.close();
|
||||
|
||||
expect(removeCalls).not.toContain("memory-root");
|
||||
expect(logDebugMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("qmd legacy collection migration skipped for memory-root"),
|
||||
);
|
||||
});
|
||||
|
||||
it("times out qmd update during sync when configured", async () => {
|
||||
vi.useFakeTimers();
|
||||
cfg = {
|
||||
|
||||
@@ -73,6 +73,13 @@ type ListedCollection = {
|
||||
pattern?: string;
|
||||
};
|
||||
|
||||
type ManagedCollection = {
|
||||
name: string;
|
||||
path: string;
|
||||
pattern: string;
|
||||
kind: "memory" | "custom" | "sessions";
|
||||
};
|
||||
|
||||
type QmdManagerMode = "full" | "status";
|
||||
|
||||
export class QmdMemoryManager implements MemorySearchManager {
|
||||
@@ -269,6 +276,8 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
// ignore; older qmd versions might not support list --json.
|
||||
}
|
||||
|
||||
await this.migrateLegacyUnscopedCollections(existing);
|
||||
|
||||
for (const collection of this.qmd.collections) {
|
||||
const listed = existing.get(collection.name);
|
||||
if (listed && !this.shouldRebindCollection(collection, listed)) {
|
||||
@@ -297,6 +306,61 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async migrateLegacyUnscopedCollections(
|
||||
existing: Map<string, ListedCollection>,
|
||||
): Promise<void> {
|
||||
for (const collection of this.qmd.collections) {
|
||||
if (existing.has(collection.name)) {
|
||||
continue;
|
||||
}
|
||||
const legacyName = this.deriveLegacyCollectionName(collection.name);
|
||||
if (!legacyName) {
|
||||
continue;
|
||||
}
|
||||
const listedLegacy = existing.get(legacyName);
|
||||
if (!listedLegacy) {
|
||||
continue;
|
||||
}
|
||||
if (!this.canMigrateLegacyCollection(collection, listedLegacy)) {
|
||||
log.debug(
|
||||
`qmd legacy collection migration skipped for ${legacyName} (path/pattern mismatch)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await this.removeCollection(legacyName);
|
||||
existing.delete(legacyName);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (!this.isCollectionMissingError(message)) {
|
||||
log.warn(`qmd collection remove failed for ${legacyName}: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private deriveLegacyCollectionName(scopedName: string): string | null {
|
||||
const agentSuffix = `-${this.sanitizeCollectionNameSegment(this.agentId)}`;
|
||||
if (!scopedName.endsWith(agentSuffix)) {
|
||||
return null;
|
||||
}
|
||||
const legacyName = scopedName.slice(0, -agentSuffix.length).trim();
|
||||
return legacyName || null;
|
||||
}
|
||||
|
||||
private canMigrateLegacyCollection(
|
||||
collection: ManagedCollection,
|
||||
listedLegacy: ListedCollection,
|
||||
): boolean {
|
||||
if (listedLegacy.path && !this.pathsMatch(listedLegacy.path, collection.path)) {
|
||||
return false;
|
||||
}
|
||||
if (typeof listedLegacy.pattern === "string" && listedLegacy.pattern !== collection.pattern) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async ensureCollectionPath(collection: {
|
||||
path: string;
|
||||
pattern: string;
|
||||
@@ -336,10 +400,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
});
|
||||
}
|
||||
|
||||
private shouldRebindCollection(
|
||||
collection: { kind: string; path: string; pattern: string },
|
||||
listed: ListedCollection,
|
||||
): boolean {
|
||||
private shouldRebindCollection(collection: ManagedCollection, listed: ListedCollection): boolean {
|
||||
if (!listed.path) {
|
||||
// Older qmd versions may only return names from `collection list --json`.
|
||||
// Rebind managed collections so stale path bindings cannot survive upgrades.
|
||||
|
||||
Reference in New Issue
Block a user