Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion src/memory/__tests__/knowledge-graph.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
Expand All @@ -18,6 +18,8 @@ describe('KnowledgeGraphManager', () => {
});

afterEach(async () => {
vi.restoreAllMocks();

// Clean up test file
try {
await fs.unlink(testFilePath);
Expand Down Expand Up @@ -409,6 +411,38 @@ describe('KnowledgeGraphManager', () => {
expect(graph.entities[0].name).toBe('Alice');
});

it('should preserve existing data when a write is interrupted', async () => {
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: ['persistent data'] },
]);

const originalFileContent = await fs.readFile(testFilePath, 'utf-8');
const writeFile = fs.writeFile.bind(fs);

// Simulate the process being killed mid-write: partial bytes written, then error
vi.spyOn(fs, 'writeFile').mockImplementation(async (file, data) => {
await writeFile(file, data.toString().slice(0, 1));
throw new Error('interrupted write');
});

await expect(
manager.createEntities([
{ name: 'Bob', entityType: 'person', observations: [] },
])
).rejects.toThrow('interrupted write');

// The original memory file must be untouched
await expect(fs.readFile(testFilePath, 'utf-8')).resolves.toBe(
originalFileContent
);

// No temp files may leak from the failed write
const files = await fs.readdir(path.dirname(testFilePath));
expect(
files.filter(file => file.startsWith(`${path.basename(testFilePath)}.`))
).toEqual([]);
});

it('should handle JSONL format correctly', async () => {
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: [] },
Expand Down
22 changes: 21 additions & 1 deletion src/memory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { SubscribeRequestSchema, UnsubscribeRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { randomBytes } from 'crypto';
import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
Expand Down Expand Up @@ -114,7 +115,26 @@ export class KnowledgeGraphManager {
relationType: r.relationType
})),
];
await fs.writeFile(this.memoryFilePath, lines.join("\n"));
// Write atomically: write to a unique temp file, then rename into place, so
// an interruption mid-write can never leave a truncated or corrupted file.
// The random suffix avoids collisions when saves overlap.
//
// Known limitation: a hard kill (SIGKILL) between the write and the rename
// leaves a stray .tmp file behind. This is unavoidable without a journal;
// the guarantee we provide is that memory.jsonl itself is never corrupted.
const tmpPath = `${this.memoryFilePath}.${randomBytes(16).toString('hex')}.tmp`;
try {
await fs.writeFile(tmpPath, lines.join("\n"));
await fs.rename(tmpPath, this.memoryFilePath);
} catch (error) {
// Clean up the temp file so a failed save never leaks artifacts.
try {
await fs.unlink(tmpPath);
} catch {
/* best-effort cleanup: never mask the original error */
}
throw error;
}
}

async createEntities(entities: Entity[]): Promise<Entity[]> {
Expand Down