diff --git a/src/memory/__tests__/atomic-save.test.ts b/src/memory/__tests__/atomic-save.test.ts index 288ff9dd2a..bb1dc8308a 100644 --- a/src/memory/__tests__/atomic-save.test.ts +++ b/src/memory/__tests__/atomic-save.test.ts @@ -142,6 +142,47 @@ describe("KnowledgeGraphManager persistence durability", () => { expect(await fs.readdir(testDir)).toEqual(["memory.jsonl"]); }); + it("writes through a symlinked memory file instead of replacing the link", async () => { + const targetDir = path.join(testDir, "synced"); + await fs.mkdir(targetDir); + const targetPath = path.join(targetDir, "memory.jsonl"); + await fs.writeFile(targetPath, ""); + await fs.symlink(targetPath, testFilePath); + + const manager = new KnowledgeGraphManager(testFilePath); + await manager.createEntities([ + { + name: "Alice", + entityType: "person", + observations: ["works at Acme Corp"], + }, + ]); + + expect((await fs.lstat(testFilePath)).isSymbolicLink()).toBe(true); + expect(await fs.readFile(targetPath, "utf-8")).toContain("Alice"); + expect(await fs.readdir(targetDir)).toEqual(["memory.jsonl"]); + }); + + it("creates the target of a symlinked memory file that does not exist yet", async () => { + const targetDir = path.join(testDir, "synced"); + await fs.mkdir(targetDir); + await fs.symlink(path.join("synced", "memory.jsonl"), testFilePath); + + const manager = new KnowledgeGraphManager(testFilePath); + await manager.createEntities([ + { + name: "Alice", + entityType: "person", + observations: ["works at Acme Corp"], + }, + ]); + + expect((await fs.lstat(testFilePath)).isSymbolicLink()).toBe(true); + expect( + await fs.readFile(path.join(targetDir, "memory.jsonl"), "utf-8"), + ).toContain("Alice"); + }); + it("still persists graph contents correctly across reloads", async () => { const manager = new KnowledgeGraphManager(testFilePath); await manager.createEntities([ diff --git a/src/memory/index.ts b/src/memory/index.ts index eab385a9fa..c203cfca89 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -475,10 +475,23 @@ export class KnowledgeGraphManager { // bits before the rename, as src/filesystem/lib.ts preserves them for // the same write pattern. A graph file that does not exist yet has // nothing to preserve. + // + // Resolve symlinks so the rename replaces the link's target, not the link. + const targetPath = await fs + .realpath(this.memoryFilePath) + .catch(async () => { + const link = await fs + .readlink(this.memoryFilePath) + .catch(() => undefined); + return link + ? path.resolve(path.dirname(this.memoryFilePath), link) + : this.memoryFilePath; + }); + let mode: number | undefined; try { - await fs.access(this.memoryFilePath, fs.constants.W_OK); - mode = (await fs.stat(this.memoryFilePath)).mode & 0o777; + await fs.access(targetPath, fs.constants.W_OK); + mode = (await fs.stat(targetPath)).mode & 0o777; } catch (error) { if ( !(error instanceof Error && "code" in error && error.code === "ENOENT") @@ -487,10 +500,10 @@ export class KnowledgeGraphManager { } } - const directory = path.dirname(this.memoryFilePath); + const directory = path.dirname(targetPath); const tempFilePath = path.join( directory, - `${path.basename(this.memoryFilePath)}.${randomBytes(16).toString("hex")}.tmp`, + `${path.basename(targetPath)}.${randomBytes(16).toString("hex")}.tmp`, ); try { @@ -506,7 +519,7 @@ export class KnowledgeGraphManager { if (mode !== undefined) { await fs.chmod(tempFilePath, mode); } - await fs.rename(tempFilePath, this.memoryFilePath); + await fs.rename(tempFilePath, targetPath); } catch (error) { // Never leave a stray temp file behind on failure. await fs.unlink(tempFilePath).catch(() => {});