refactor: sync simplification (#752)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -31,15 +31,27 @@ const MANAGED_BLOCK_LABELS = new Set([
|
||||
...ISOLATED_BLOCK_LABELS,
|
||||
]);
|
||||
|
||||
// Unified sync state - no system/detached split
|
||||
// The attached/detached distinction is derived at runtime from API and FS
|
||||
type SyncState = {
|
||||
systemBlocks: Record<string, string>;
|
||||
systemFiles: Record<string, string>;
|
||||
detachedBlocks: Record<string, string>;
|
||||
detachedFiles: Record<string, string>;
|
||||
detachedBlockIds: Record<string, string>;
|
||||
blockHashes: Record<string, string>; // label → content hash
|
||||
fileHashes: Record<string, string>; // label → content hash
|
||||
blockIds: Record<string, string>; // label → block ID
|
||||
lastSync: string | null;
|
||||
};
|
||||
|
||||
// Legacy format for migration
|
||||
type LegacySyncState = {
|
||||
systemBlocks?: Record<string, string>;
|
||||
systemFiles?: Record<string, string>;
|
||||
detachedBlocks?: Record<string, string>;
|
||||
detachedFiles?: Record<string, string>;
|
||||
detachedBlockIds?: Record<string, string>;
|
||||
blocks?: Record<string, string>;
|
||||
files?: Record<string, string>;
|
||||
lastSync?: string | null;
|
||||
};
|
||||
|
||||
export type MemorySyncConflict = {
|
||||
label: string;
|
||||
blockValue: string | null;
|
||||
@@ -57,6 +69,8 @@ export type MemfsSyncStatus = {
|
||||
newFiles: string[];
|
||||
/** Labels where a block exists but no file */
|
||||
newBlocks: string[];
|
||||
/** Labels where file location doesn't match block attachment (would auto-sync) */
|
||||
locationMismatches: string[];
|
||||
/** True when there are no conflicts or pending changes */
|
||||
isClean: boolean;
|
||||
};
|
||||
@@ -140,40 +154,52 @@ function loadSyncState(
|
||||
homeDir: string = homedir(),
|
||||
): SyncState {
|
||||
const statePath = getMemoryStatePath(agentId, homeDir);
|
||||
const emptyState: SyncState = {
|
||||
blockHashes: {},
|
||||
fileHashes: {},
|
||||
blockIds: {},
|
||||
lastSync: null,
|
||||
};
|
||||
|
||||
if (!existsSync(statePath)) {
|
||||
return {
|
||||
systemBlocks: {},
|
||||
systemFiles: {},
|
||||
detachedBlocks: {},
|
||||
detachedFiles: {},
|
||||
detachedBlockIds: {},
|
||||
lastSync: null,
|
||||
};
|
||||
return emptyState;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(statePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Partial<SyncState> & {
|
||||
blocks?: Record<string, string>;
|
||||
files?: Record<string, string>;
|
||||
const parsed = JSON.parse(raw) as LegacySyncState & Partial<SyncState>;
|
||||
|
||||
// New format - return directly
|
||||
if (parsed.blockHashes !== undefined) {
|
||||
return {
|
||||
blockHashes: parsed.blockHashes || {},
|
||||
fileHashes: parsed.fileHashes || {},
|
||||
blockIds: parsed.blockIds || {},
|
||||
lastSync: parsed.lastSync || null,
|
||||
};
|
||||
}
|
||||
|
||||
// Migrate from legacy format: merge system + detached into unified maps
|
||||
const blockHashes: Record<string, string> = {
|
||||
...(parsed.systemBlocks || parsed.blocks || {}),
|
||||
...(parsed.detachedBlocks || {}),
|
||||
};
|
||||
const fileHashes: Record<string, string> = {
|
||||
...(parsed.systemFiles || parsed.files || {}),
|
||||
...(parsed.detachedFiles || {}),
|
||||
};
|
||||
const blockIds: Record<string, string> = {
|
||||
...(parsed.detachedBlockIds || {}),
|
||||
};
|
||||
|
||||
return {
|
||||
systemBlocks: parsed.systemBlocks || parsed.blocks || {},
|
||||
systemFiles: parsed.systemFiles || parsed.files || {},
|
||||
detachedBlocks: parsed.detachedBlocks || {},
|
||||
detachedFiles: parsed.detachedFiles || {},
|
||||
detachedBlockIds: parsed.detachedBlockIds || {},
|
||||
blockHashes,
|
||||
fileHashes,
|
||||
blockIds,
|
||||
lastSync: parsed.lastSync || null,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
systemBlocks: {},
|
||||
systemFiles: {},
|
||||
detachedBlocks: {},
|
||||
detachedFiles: {},
|
||||
detachedBlockIds: {},
|
||||
lastSync: null,
|
||||
};
|
||||
return emptyState;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,39 +454,28 @@ export function renderMemoryFilesystemTree(
|
||||
}
|
||||
|
||||
function buildStateHashes(
|
||||
systemBlocks: Map<string, { value: string }>,
|
||||
systemFiles: Map<string, { content: string }>,
|
||||
detachedBlocks: Map<string, { value: string }>,
|
||||
detachedFiles: Map<string, { content: string }>,
|
||||
detachedBlockIds: Record<string, string>,
|
||||
allBlocks: Map<string, { value?: string | null; id?: string }>,
|
||||
allFiles: Map<string, { content: string }>,
|
||||
): SyncState {
|
||||
const systemBlockHashes: Record<string, string> = {};
|
||||
const systemFileHashes: Record<string, string> = {};
|
||||
const userBlockHashes: Record<string, string> = {};
|
||||
const userFileHashes: Record<string, string> = {};
|
||||
const blockHashes: Record<string, string> = {};
|
||||
const fileHashes: Record<string, string> = {};
|
||||
const blockIds: Record<string, string> = {};
|
||||
|
||||
systemBlocks.forEach((block, label) => {
|
||||
systemBlockHashes[label] = hashContent(block.value || "");
|
||||
allBlocks.forEach((block, label) => {
|
||||
blockHashes[label] = hashContent(block.value || "");
|
||||
if (block.id) {
|
||||
blockIds[label] = block.id;
|
||||
}
|
||||
});
|
||||
|
||||
systemFiles.forEach((file, label) => {
|
||||
systemFileHashes[label] = hashContent(file.content || "");
|
||||
});
|
||||
|
||||
detachedBlocks.forEach((block, label) => {
|
||||
userBlockHashes[label] = hashContent(block.value || "");
|
||||
});
|
||||
|
||||
detachedFiles.forEach((file, label) => {
|
||||
userFileHashes[label] = hashContent(file.content || "");
|
||||
allFiles.forEach((file, label) => {
|
||||
fileHashes[label] = hashContent(file.content || "");
|
||||
});
|
||||
|
||||
return {
|
||||
systemBlocks: systemBlockHashes,
|
||||
systemFiles: systemFileHashes,
|
||||
detachedBlocks: userBlockHashes,
|
||||
detachedFiles: userFileHashes,
|
||||
detachedBlockIds,
|
||||
blockHashes,
|
||||
fileHashes,
|
||||
blockIds,
|
||||
lastSync: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -508,13 +523,12 @@ export async function syncMemoryFilesystem(
|
||||
// Backfill owner tags on attached blocks (for backwards compat)
|
||||
await backfillOwnerTags(agentId, attachedBlocks);
|
||||
|
||||
// Discover detached blocks via owner tag (replaces detachedBlockIds tracking)
|
||||
// Discover detached blocks via owner tag
|
||||
const allOwnedBlocks = await fetchOwnedBlocks(agentId);
|
||||
const attachedIds = new Set(attachedBlocks.map((b) => b.id));
|
||||
const detachedBlocks = allOwnedBlocks.filter((b) => !attachedIds.has(b.id));
|
||||
|
||||
// Build detached block map and IDs (for sync state compatibility)
|
||||
const detachedBlockIds: Record<string, string> = {};
|
||||
// Build detached block map
|
||||
const detachedBlockMap = new Map<string, Block>();
|
||||
for (const block of detachedBlocks) {
|
||||
if (block.label && block.id) {
|
||||
@@ -527,72 +541,121 @@ export async function syncMemoryFilesystem(
|
||||
if (systemBlockMap.has(block.label)) {
|
||||
continue;
|
||||
}
|
||||
detachedBlockIds[block.label] = block.id;
|
||||
detachedBlockMap.set(block.label, block);
|
||||
}
|
||||
}
|
||||
|
||||
const systemLabels = new Set<string>([
|
||||
// Unified sync loop - collect all labels and process once
|
||||
// The attached/detached distinction is determined at runtime
|
||||
const allLabels = new Set<string>([
|
||||
...Array.from(systemFiles.keys()),
|
||||
...Array.from(detachedFiles.keys()),
|
||||
...Array.from(systemBlockMap.keys()),
|
||||
...Object.keys(lastState.systemBlocks),
|
||||
...Object.keys(lastState.systemFiles),
|
||||
...Array.from(detachedBlockMap.keys()),
|
||||
...Object.keys(lastState.blockHashes),
|
||||
...Object.keys(lastState.fileHashes),
|
||||
]);
|
||||
|
||||
for (const label of Array.from(systemLabels).sort()) {
|
||||
// Track all blocks for state saving
|
||||
const allBlocksMap = new Map<
|
||||
string,
|
||||
{ value?: string | null; id?: string }
|
||||
>();
|
||||
const allFilesMap = new Map<string, { content: string }>();
|
||||
|
||||
for (const label of Array.from(allLabels).sort()) {
|
||||
if (MANAGED_BLOCK_LABELS.has(label)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileEntry = systemFiles.get(label);
|
||||
const blockEntry = systemBlockMap.get(label);
|
||||
// Determine current state at runtime
|
||||
const systemFile = systemFiles.get(label);
|
||||
const detachedFile = detachedFiles.get(label);
|
||||
const attachedBlock = systemBlockMap.get(label);
|
||||
const detachedBlock = detachedBlockMap.get(label);
|
||||
|
||||
// Derive file and block entries
|
||||
const fileEntry = systemFile || detachedFile;
|
||||
const fileInSystem = !!systemFile;
|
||||
const blockEntry = attachedBlock || detachedBlock;
|
||||
const isAttached = !!attachedBlock;
|
||||
|
||||
// Get directory for file operations
|
||||
const fileDir = fileInSystem ? systemDir : detachedDir;
|
||||
|
||||
const fileHash = fileEntry ? hashContent(fileEntry.content) : null;
|
||||
const blockHash = blockEntry ? hashContent(blockEntry.value || "") : null;
|
||||
|
||||
const lastFileHash = lastState.systemFiles[label] || null;
|
||||
const lastBlockHash = lastState.systemBlocks[label] || null;
|
||||
// Use unified hash lookup
|
||||
const lastFileHash = lastState.fileHashes[label] || null;
|
||||
const lastBlockHash = lastState.blockHashes[label] || null;
|
||||
|
||||
const fileChanged = fileHash !== lastFileHash;
|
||||
const blockChanged = blockHash !== lastBlockHash;
|
||||
|
||||
const resolution = resolutions.get(label);
|
||||
|
||||
// Track for state saving
|
||||
if (blockEntry) {
|
||||
allBlocksMap.set(label, { value: blockEntry.value, id: blockEntry.id });
|
||||
}
|
||||
if (fileEntry) {
|
||||
allFilesMap.set(label, { content: fileEntry.content });
|
||||
}
|
||||
|
||||
// Case 1: File exists, no block
|
||||
if (fileEntry && !blockEntry) {
|
||||
if (lastBlockHash && !fileChanged) {
|
||||
// Block was deleted elsewhere; delete file.
|
||||
await deleteMemoryFile(systemDir, label);
|
||||
// Block was deleted elsewhere; delete file
|
||||
await deleteMemoryFile(fileDir, label);
|
||||
deletedFiles.push(label);
|
||||
allFilesMap.delete(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create block from file (parsing frontmatter for description/limit)
|
||||
// Create block from file
|
||||
const blockData = parseBlockFromFileContent(fileEntry.content, label);
|
||||
const createdBlock = await client.blocks.create({
|
||||
...blockData,
|
||||
tags: [`owner:${agentId}`],
|
||||
});
|
||||
if (createdBlock.id) {
|
||||
await client.agents.blocks.attach(createdBlock.id, {
|
||||
agent_id: agentId,
|
||||
// Policy: attach if file is in system/, don't attach if at root
|
||||
if (fileInSystem) {
|
||||
await client.agents.blocks.attach(createdBlock.id, {
|
||||
agent_id: agentId,
|
||||
});
|
||||
}
|
||||
allBlocksMap.set(label, {
|
||||
value: createdBlock.value,
|
||||
id: createdBlock.id,
|
||||
});
|
||||
}
|
||||
createdBlocks.push(blockData.label);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Case 2: Block exists, no file
|
||||
if (!fileEntry && blockEntry) {
|
||||
if (lastFileHash && !blockChanged) {
|
||||
// File deleted, block unchanged -> detach only (block stays with owner tag)
|
||||
// File deleted, block unchanged → remove owner tag so file doesn't resurrect
|
||||
if (blockEntry.id) {
|
||||
try {
|
||||
await client.agents.blocks.detach(blockEntry.id, {
|
||||
agent_id: agentId,
|
||||
});
|
||||
// Note: Don't delete the block - it keeps its owner tag for potential recovery
|
||||
if (isAttached) {
|
||||
// Detach the attached block first
|
||||
await client.agents.blocks.detach(blockEntry.id, {
|
||||
agent_id: agentId,
|
||||
});
|
||||
}
|
||||
// Remove owner tag from block
|
||||
const currentTags = blockEntry.tags || [];
|
||||
const newTags = currentTags.filter(
|
||||
(tag) => !tag.startsWith(`owner:${agentId}`),
|
||||
);
|
||||
await client.blocks.update(blockEntry.id, { tags: newTags });
|
||||
allBlocksMap.delete(label);
|
||||
deletedBlocks.push(label);
|
||||
} catch (err) {
|
||||
// Block may have been manually deleted already - ignore
|
||||
if (!(err instanceof Error && err.message.includes("Not Found"))) {
|
||||
throw err;
|
||||
}
|
||||
@@ -601,60 +664,125 @@ export async function syncMemoryFilesystem(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create file from block
|
||||
await writeMemoryFile(systemDir, label, blockEntry.value || "");
|
||||
// Create file from block - use block's attached status to determine location
|
||||
const targetDir = isAttached ? systemDir : detachedDir;
|
||||
await writeMemoryFile(targetDir, label, blockEntry.value || "");
|
||||
createdFiles.push(label);
|
||||
allFilesMap.set(label, { content: blockEntry.value || "" });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Case 3: Neither exists (was in lastState but now gone)
|
||||
if (!fileEntry || !blockEntry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If file and block have the same content, they're in sync - no conflict
|
||||
// Case 4: Both exist - check for sync/conflict/location mismatch
|
||||
|
||||
// Check for location mismatch: file location doesn't match block attachment
|
||||
const locationMismatch =
|
||||
(fileInSystem && !isAttached) || (!fileInSystem && isAttached);
|
||||
|
||||
// If content matches but location mismatches, sync attachment to match file location
|
||||
if (fileHash === blockHash) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fileChanged && blockChanged && !resolution) {
|
||||
conflicts.push({
|
||||
label,
|
||||
blockValue: blockEntry.value || "",
|
||||
fileValue: fileEntry.content,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resolution?.resolution === "file") {
|
||||
if (blockEntry.id) {
|
||||
// Parse frontmatter to extract just the body for the block value
|
||||
const blockData = parseBlockFromFileContent(fileEntry.content, label);
|
||||
await client.blocks.update(blockEntry.id, {
|
||||
value: blockData.value,
|
||||
});
|
||||
updatedBlocks.push(label);
|
||||
if (locationMismatch && blockEntry.id) {
|
||||
if (fileInSystem && !isAttached) {
|
||||
// File in system/, block detached → attach block
|
||||
await client.agents.blocks.attach(blockEntry.id, {
|
||||
agent_id: agentId,
|
||||
});
|
||||
} else if (!fileInSystem && isAttached) {
|
||||
// File at root, block attached → detach block
|
||||
await client.agents.blocks.detach(blockEntry.id, {
|
||||
agent_id: agentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resolution?.resolution === "block") {
|
||||
await writeMemoryFile(systemDir, label, blockEntry.value || "");
|
||||
// "FS wins all" policy: if file changed, file wins (even if block also changed)
|
||||
// Only conflict if explicit resolution provided but doesn't match
|
||||
if (
|
||||
fileChanged &&
|
||||
blockChanged &&
|
||||
resolution &&
|
||||
resolution.resolution === "block"
|
||||
) {
|
||||
// User explicitly requested block wins via resolution for CONTENT
|
||||
// But FS still wins for LOCATION (attachment status)
|
||||
await writeMemoryFile(fileDir, label, blockEntry.value || "");
|
||||
updatedFiles.push(label);
|
||||
allFilesMap.set(label, { content: blockEntry.value || "" });
|
||||
|
||||
// Sync attachment status to match file location (FS wins for location)
|
||||
if (locationMismatch && blockEntry.id) {
|
||||
if (fileInSystem && !isAttached) {
|
||||
await client.agents.blocks.attach(blockEntry.id, {
|
||||
agent_id: agentId,
|
||||
});
|
||||
} else if (!fileInSystem && isAttached) {
|
||||
await client.agents.blocks.detach(blockEntry.id, {
|
||||
agent_id: agentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fileChanged && !blockChanged) {
|
||||
// Handle explicit resolution override
|
||||
if (resolution?.resolution === "block") {
|
||||
// Block wins for CONTENT, but FS wins for LOCATION
|
||||
await writeMemoryFile(fileDir, label, blockEntry.value || "");
|
||||
updatedFiles.push(label);
|
||||
allFilesMap.set(label, { content: blockEntry.value || "" });
|
||||
|
||||
// Sync attachment status to match file location (FS wins for location)
|
||||
if (locationMismatch && blockEntry.id) {
|
||||
if (fileInSystem && !isAttached) {
|
||||
await client.agents.blocks.attach(blockEntry.id, {
|
||||
agent_id: agentId,
|
||||
});
|
||||
} else if (!fileInSystem && isAttached) {
|
||||
await client.agents.blocks.detach(blockEntry.id, {
|
||||
agent_id: agentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// "FS wins all": if file changed at all, file wins (update block from file)
|
||||
// Also sync attachment status to match file location
|
||||
if (fileChanged) {
|
||||
if (blockEntry.id) {
|
||||
try {
|
||||
// Parse frontmatter to extract just the body for the block value
|
||||
const blockData = parseBlockFromFileContent(fileEntry.content, label);
|
||||
await client.blocks.update(blockEntry.id, {
|
||||
value: blockData.value,
|
||||
});
|
||||
const updatePayload = isAttached
|
||||
? { value: blockData.value }
|
||||
: { value: blockData.value, label };
|
||||
await client.blocks.update(blockEntry.id, updatePayload);
|
||||
updatedBlocks.push(label);
|
||||
allBlocksMap.set(label, {
|
||||
value: blockData.value,
|
||||
id: blockEntry.id,
|
||||
});
|
||||
|
||||
// Sync attachment status to match file location (FS wins for location too)
|
||||
if (locationMismatch) {
|
||||
if (fileInSystem && !isAttached) {
|
||||
await client.agents.blocks.attach(blockEntry.id, {
|
||||
agent_id: agentId,
|
||||
});
|
||||
} else if (!fileInSystem && isAttached) {
|
||||
await client.agents.blocks.detach(blockEntry.id, {
|
||||
agent_id: agentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Block may have been deleted - create a new one
|
||||
if (err instanceof Error && err.message.includes("Not Found")) {
|
||||
// Block was deleted - create a new one
|
||||
const blockData = parseBlockFromFileContent(
|
||||
fileEntry.content,
|
||||
label,
|
||||
@@ -664,8 +792,14 @@ export async function syncMemoryFilesystem(
|
||||
tags: [`owner:${agentId}`],
|
||||
});
|
||||
if (createdBlock.id) {
|
||||
await client.agents.blocks.attach(createdBlock.id, {
|
||||
agent_id: agentId,
|
||||
if (fileInSystem) {
|
||||
await client.agents.blocks.attach(createdBlock.id, {
|
||||
agent_id: agentId,
|
||||
});
|
||||
}
|
||||
allBlocksMap.set(label, {
|
||||
value: createdBlock.value,
|
||||
id: createdBlock.id,
|
||||
});
|
||||
}
|
||||
createdBlocks.push(blockData.label);
|
||||
@@ -677,160 +811,31 @@ export async function syncMemoryFilesystem(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fileChanged && blockChanged) {
|
||||
await writeMemoryFile(systemDir, label, blockEntry.value || "");
|
||||
updatedFiles.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
const detachedLabels = new Set<string>([
|
||||
...Array.from(detachedFiles.keys()),
|
||||
...Array.from(detachedBlockMap.keys()),
|
||||
...Object.keys(lastState.detachedBlocks),
|
||||
...Object.keys(lastState.detachedFiles),
|
||||
]);
|
||||
|
||||
for (const label of Array.from(detachedLabels).sort()) {
|
||||
const fileEntry = detachedFiles.get(label);
|
||||
const blockEntry = detachedBlockMap.get(label);
|
||||
|
||||
const fileHash = fileEntry ? hashContent(fileEntry.content) : null;
|
||||
const blockHash = blockEntry ? hashContent(blockEntry.value || "") : null;
|
||||
|
||||
const lastFileHash = lastState.detachedFiles[label] || null;
|
||||
const lastBlockHash = lastState.detachedBlocks[label] || null;
|
||||
|
||||
const fileChanged = fileHash !== lastFileHash;
|
||||
const blockChanged = blockHash !== lastBlockHash;
|
||||
|
||||
const resolution = resolutions.get(label);
|
||||
|
||||
if (fileEntry && !blockEntry) {
|
||||
if (lastBlockHash && !fileChanged) {
|
||||
// Block was deleted elsewhere; delete file.
|
||||
await deleteMemoryFile(detachedDir, label);
|
||||
deletedFiles.push(label);
|
||||
delete detachedBlockIds[label];
|
||||
continue;
|
||||
}
|
||||
|
||||
const blockData = parseBlockFromFileContent(fileEntry.content, label);
|
||||
const createdBlock = await client.blocks.create({
|
||||
...blockData,
|
||||
tags: [`owner:${agentId}`],
|
||||
});
|
||||
if (createdBlock.id) {
|
||||
detachedBlockIds[blockData.label] = createdBlock.id;
|
||||
detachedBlockMap.set(blockData.label, createdBlock as Block);
|
||||
}
|
||||
createdBlocks.push(blockData.label);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fileEntry && blockEntry) {
|
||||
if (lastFileHash && !blockChanged) {
|
||||
// File deleted, block unchanged -> just remove from tracking (block keeps owner tag)
|
||||
// Note: Don't delete the block - it stays discoverable via owner tag
|
||||
deletedBlocks.push(label);
|
||||
delete detachedBlockIds[label];
|
||||
continue;
|
||||
}
|
||||
|
||||
await writeMemoryFile(detachedDir, label, blockEntry.value || "");
|
||||
createdFiles.push(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fileEntry || !blockEntry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If file and block have the same content, they're in sync - no conflict
|
||||
if (fileHash === blockHash) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fileChanged && blockChanged && !resolution) {
|
||||
conflicts.push({
|
||||
label,
|
||||
blockValue: blockEntry.value || "",
|
||||
fileValue: fileEntry.content,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resolution?.resolution === "file") {
|
||||
if (blockEntry.id) {
|
||||
// Parse frontmatter to extract just the body for the block value
|
||||
const blockData = parseBlockFromFileContent(fileEntry.content, label);
|
||||
await client.blocks.update(blockEntry.id, {
|
||||
value: blockData.value,
|
||||
label,
|
||||
});
|
||||
}
|
||||
updatedBlocks.push(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resolution?.resolution === "block") {
|
||||
await writeMemoryFile(detachedDir, label, blockEntry.value || "");
|
||||
updatedFiles.push(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fileChanged && !blockChanged) {
|
||||
if (blockEntry.id) {
|
||||
// Parse frontmatter to extract just the body for the block value
|
||||
const blockData = parseBlockFromFileContent(fileEntry.content, label);
|
||||
await client.blocks.update(blockEntry.id, {
|
||||
value: blockData.value,
|
||||
label,
|
||||
});
|
||||
}
|
||||
updatedBlocks.push(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fileChanged && blockChanged) {
|
||||
await writeMemoryFile(detachedDir, label, blockEntry.value || "");
|
||||
// Only block changed (file unchanged) → update file from block
|
||||
// Also sync attachment status to match file location
|
||||
if (blockChanged) {
|
||||
await writeMemoryFile(fileDir, label, blockEntry.value || "");
|
||||
updatedFiles.push(label);
|
||||
allFilesMap.set(label, { content: blockEntry.value || "" });
|
||||
|
||||
// Sync attachment status to match file location (FS wins for location)
|
||||
if (locationMismatch && blockEntry.id) {
|
||||
if (fileInSystem && !isAttached) {
|
||||
await client.agents.blocks.attach(blockEntry.id, {
|
||||
agent_id: agentId,
|
||||
});
|
||||
} else if (!fileInSystem && isAttached) {
|
||||
await client.agents.blocks.detach(blockEntry.id, {
|
||||
agent_id: agentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save state if no conflicts
|
||||
if (conflicts.length === 0) {
|
||||
const updatedBlocksList = await fetchAgentBlocks(agentId);
|
||||
const updatedSystemBlockMap = new Map(
|
||||
updatedBlocksList
|
||||
.filter(
|
||||
(block) =>
|
||||
block.label && block.label !== MEMORY_FILESYSTEM_BLOCK_LABEL,
|
||||
)
|
||||
.map((block) => [block.label as string, { value: block.value || "" }]),
|
||||
);
|
||||
|
||||
const updatedSystemFilesMap = await readMemoryFiles(systemDir);
|
||||
updatedSystemFilesMap.delete(MEMORY_FILESYSTEM_BLOCK_LABEL);
|
||||
const updatedUserFilesMap = await readMemoryFiles(detachedDir, [
|
||||
MEMORY_SYSTEM_DIR,
|
||||
]);
|
||||
const refreshedUserBlocks = new Map<string, { value: string }>();
|
||||
|
||||
for (const [label, blockId] of Object.entries(detachedBlockIds)) {
|
||||
try {
|
||||
const block = await client.blocks.retrieve(blockId);
|
||||
refreshedUserBlocks.set(label, { value: block.value || "" });
|
||||
} catch {
|
||||
delete detachedBlockIds[label];
|
||||
}
|
||||
}
|
||||
|
||||
const nextState = buildStateHashes(
|
||||
updatedSystemBlockMap,
|
||||
updatedSystemFilesMap,
|
||||
refreshedUserBlocks,
|
||||
updatedUserFilesMap,
|
||||
detachedBlockIds,
|
||||
);
|
||||
const nextState = buildStateHashes(allBlocksMap, allFilesMap);
|
||||
await saveSyncState(nextState, agentId, homeDir);
|
||||
}
|
||||
|
||||
@@ -979,6 +984,7 @@ export async function checkMemoryFilesystemStatus(
|
||||
const pendingFromBlock: string[] = [];
|
||||
const newFiles: string[] = [];
|
||||
const newBlocks: string[] = [];
|
||||
const locationMismatches: string[] = [];
|
||||
|
||||
// Discover detached blocks via owner tag
|
||||
const allOwnedBlocks = await fetchOwnedBlocks(agentId);
|
||||
@@ -1000,45 +1006,46 @@ export async function checkMemoryFilesystemStatus(
|
||||
}
|
||||
}
|
||||
|
||||
// Check system labels
|
||||
const systemLabels = new Set<string>([
|
||||
// Unified label check - collect all labels and classify once
|
||||
const allLabels = new Set<string>([
|
||||
...Array.from(systemFiles.keys()),
|
||||
...Array.from(systemBlockMap.keys()),
|
||||
...Object.keys(lastState.systemBlocks),
|
||||
...Object.keys(lastState.systemFiles),
|
||||
]);
|
||||
|
||||
for (const label of Array.from(systemLabels).sort()) {
|
||||
if (MANAGED_BLOCK_LABELS.has(label)) continue;
|
||||
classifyLabel(
|
||||
label,
|
||||
systemFiles.get(label)?.content ?? null,
|
||||
systemBlockMap.get(label)?.value ?? null,
|
||||
lastState.systemFiles[label] ?? null,
|
||||
lastState.systemBlocks[label] ?? null,
|
||||
conflicts,
|
||||
pendingFromFile,
|
||||
pendingFromBlock,
|
||||
newFiles,
|
||||
newBlocks,
|
||||
);
|
||||
}
|
||||
|
||||
// Check user labels
|
||||
const detachedLabels = new Set<string>([
|
||||
...Array.from(detachedFiles.keys()),
|
||||
...Array.from(systemBlockMap.keys()),
|
||||
...Array.from(detachedBlockMap.keys()),
|
||||
...Object.keys(lastState.detachedBlocks),
|
||||
...Object.keys(lastState.detachedFiles),
|
||||
...Object.keys(lastState.blockHashes),
|
||||
...Object.keys(lastState.fileHashes),
|
||||
]);
|
||||
|
||||
for (const label of Array.from(detachedLabels).sort()) {
|
||||
for (const label of Array.from(allLabels).sort()) {
|
||||
if (MANAGED_BLOCK_LABELS.has(label)) continue;
|
||||
|
||||
// Determine current state at runtime
|
||||
const systemFile = systemFiles.get(label);
|
||||
const detachedFile = detachedFiles.get(label);
|
||||
const attachedBlock = systemBlockMap.get(label);
|
||||
const detachedBlock = detachedBlockMap.get(label);
|
||||
|
||||
const fileContent = systemFile?.content ?? detachedFile?.content ?? null;
|
||||
const blockValue = attachedBlock?.value ?? detachedBlock?.value ?? null;
|
||||
|
||||
const fileInSystem = !!systemFile;
|
||||
const isAttached = !!attachedBlock;
|
||||
|
||||
// Check for location mismatch (both file and block exist but location doesn't match)
|
||||
if (fileContent !== null && blockValue !== null) {
|
||||
const locationMismatch =
|
||||
(fileInSystem && !isAttached) || (!fileInSystem && isAttached);
|
||||
if (locationMismatch) {
|
||||
locationMismatches.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
classifyLabel(
|
||||
label,
|
||||
detachedFiles.get(label)?.content ?? null,
|
||||
detachedBlockMap.get(label)?.value ?? null,
|
||||
lastState.detachedFiles[label] ?? null,
|
||||
lastState.detachedBlocks[label] ?? null,
|
||||
fileContent,
|
||||
blockValue,
|
||||
lastState.fileHashes[label] ?? null,
|
||||
lastState.blockHashes[label] ?? null,
|
||||
conflicts,
|
||||
pendingFromFile,
|
||||
pendingFromBlock,
|
||||
@@ -1052,7 +1059,8 @@ export async function checkMemoryFilesystemStatus(
|
||||
pendingFromFile.length === 0 &&
|
||||
pendingFromBlock.length === 0 &&
|
||||
newFiles.length === 0 &&
|
||||
newBlocks.length === 0;
|
||||
newBlocks.length === 0 &&
|
||||
locationMismatches.length === 0;
|
||||
|
||||
return {
|
||||
conflicts,
|
||||
@@ -1060,6 +1068,7 @@ export async function checkMemoryFilesystemStatus(
|
||||
pendingFromBlock,
|
||||
newFiles,
|
||||
newBlocks,
|
||||
locationMismatches,
|
||||
isClean,
|
||||
};
|
||||
}
|
||||
@@ -1074,7 +1083,7 @@ function classifyLabel(
|
||||
blockValue: string | null,
|
||||
lastFileHash: string | null,
|
||||
lastBlockHash: string | null,
|
||||
conflicts: MemorySyncConflict[],
|
||||
_conflicts: MemorySyncConflict[], // Unused with "FS wins all" policy (kept for API compatibility)
|
||||
pendingFromFile: string[],
|
||||
pendingFromBlock: string[],
|
||||
newFiles: string[],
|
||||
@@ -1113,17 +1122,15 @@ function classifyLabel(
|
||||
return; // In sync
|
||||
}
|
||||
|
||||
if (fileChanged && blockChanged) {
|
||||
conflicts.push({ label, blockValue, fileValue: fileContent });
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileChanged && !blockChanged) {
|
||||
// "FS wins all" policy: if file changed at all, file wins
|
||||
// So both-changed is treated as pendingFromFile, not a conflict
|
||||
if (fileChanged) {
|
||||
pendingFromFile.push(label);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fileChanged && blockChanged) {
|
||||
// Only block changed
|
||||
if (blockChanged) {
|
||||
pendingFromBlock.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2076,12 +2076,13 @@ export default function App({
|
||||
pendingMemfsConflictsRef.current = status.conflicts;
|
||||
} else if (
|
||||
status.newFiles.length > 0 ||
|
||||
status.pendingFromFile.length > 0
|
||||
status.pendingFromFile.length > 0 ||
|
||||
status.locationMismatches.length > 0
|
||||
) {
|
||||
// New files or file changes detected - auto-sync
|
||||
// New files, file changes, or location mismatches detected - auto-sync
|
||||
debugLog(
|
||||
"memfs",
|
||||
`Auto-syncing: ${status.newFiles.length} new, ${status.pendingFromFile.length} changed`,
|
||||
`Auto-syncing: ${status.newFiles.length} new, ${status.pendingFromFile.length} changed, ${status.locationMismatches.length} location mismatches`,
|
||||
);
|
||||
pendingMemfsConflictsRef.current = null;
|
||||
await runMemoryFilesystemSync("auto");
|
||||
|
||||
492
src/tests/agent/memoryFilesystem.sync.integration.test.ts
Normal file
492
src/tests/agent/memoryFilesystem.sync.integration.test.ts
Normal file
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* Integration tests for memory filesystem sync behavior.
|
||||
* These tests hit the real Letta API and require LETTA_API_KEY to be set.
|
||||
*
|
||||
* Tests cover:
|
||||
* - Bug 1: File move from system/ to root/ (should detach, not duplicate)
|
||||
* - Bug 2: File deletion (should remove owner tag, not resurrect)
|
||||
* - FS wins all policy (when both changed, file wins)
|
||||
* - Location mismatch auto-sync
|
||||
*
|
||||
* Run with: bun test src/tests/agent/memoryFilesystem.sync.integration.test.ts
|
||||
*/
|
||||
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
test,
|
||||
} from "bun:test";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import Letta from "@letta-ai/letta-client";
|
||||
|
||||
import {
|
||||
checkMemoryFilesystemStatus,
|
||||
ensureMemoryFilesystemDirs,
|
||||
getMemoryDetachedDir,
|
||||
getMemorySystemDir,
|
||||
syncMemoryFilesystem,
|
||||
} from "../../agent/memoryFilesystem";
|
||||
import { settingsManager } from "../../settings-manager";
|
||||
|
||||
// Skip all tests if no API key is available
|
||||
const LETTA_API_KEY = process.env.LETTA_API_KEY;
|
||||
const LETTA_BASE_URL = process.env.LETTA_BASE_URL || "https://api.letta.com";
|
||||
|
||||
const describeIntegration = LETTA_API_KEY ? describe : describe.skip;
|
||||
|
||||
describeIntegration("memfs sync integration", () => {
|
||||
let client: Letta;
|
||||
let testAgentId: string;
|
||||
let tempHomeDir: string;
|
||||
let originalHome: string | undefined;
|
||||
const createdBlockIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
client = new Letta({
|
||||
baseURL: LETTA_BASE_URL,
|
||||
apiKey: LETTA_API_KEY!,
|
||||
});
|
||||
|
||||
// Create a test agent
|
||||
const agent = await client.agents.create({
|
||||
name: `memfs-sync-test-${Date.now()}`,
|
||||
model: "letta/letta-free",
|
||||
embedding: "letta/letta-free",
|
||||
});
|
||||
testAgentId = agent.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up: delete created blocks
|
||||
for (const blockId of createdBlockIds) {
|
||||
try {
|
||||
await client.blocks.delete(blockId);
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Delete test agent
|
||||
if (testAgentId) {
|
||||
try {
|
||||
await client.agents.delete(testAgentId);
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset settings manager before changing HOME
|
||||
await settingsManager.reset();
|
||||
|
||||
// Create temp directory and override HOME
|
||||
tempHomeDir = join(tmpdir(), `memfs-sync-test-${Date.now()}`);
|
||||
mkdirSync(tempHomeDir, { recursive: true });
|
||||
originalHome = process.env.HOME;
|
||||
process.env.HOME = tempHomeDir;
|
||||
|
||||
// Create settings with API base URL
|
||||
// API key is read from process.env.LETTA_API_KEY by getClient()
|
||||
const settingsDir = join(tempHomeDir, ".letta");
|
||||
mkdirSync(settingsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(settingsDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
env: {
|
||||
LETTA_BASE_URL: LETTA_BASE_URL,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Initialize settings manager with new HOME
|
||||
await settingsManager.initialize();
|
||||
|
||||
// Set up memfs directories
|
||||
ensureMemoryFilesystemDirs(testAgentId, tempHomeDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Reset settings manager
|
||||
await settingsManager.reset();
|
||||
|
||||
// Restore HOME
|
||||
process.env.HOME = originalHome;
|
||||
|
||||
// Clean up temp directory
|
||||
if (tempHomeDir && existsSync(tempHomeDir)) {
|
||||
rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function getSystemDir(): string {
|
||||
return getMemorySystemDir(testAgentId, tempHomeDir);
|
||||
}
|
||||
|
||||
function getDetachedDir(): string {
|
||||
return getMemoryDetachedDir(testAgentId, tempHomeDir);
|
||||
}
|
||||
|
||||
function writeSystemFile(label: string, content: string): void {
|
||||
const systemDir = getSystemDir();
|
||||
const filePath = join(systemDir, `${label}.md`);
|
||||
const dir = join(systemDir, ...label.split("/").slice(0, -1));
|
||||
if (label.includes("/")) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
function writeDetachedFile(label: string, content: string): void {
|
||||
const detachedDir = getDetachedDir();
|
||||
const filePath = join(detachedDir, `${label}.md`);
|
||||
const dir = join(detachedDir, ...label.split("/").slice(0, -1));
|
||||
if (label.includes("/")) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
function deleteFile(dir: string, label: string): void {
|
||||
const filePath = join(dir, `${label}.md`);
|
||||
if (existsSync(filePath)) {
|
||||
rmSync(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
function readFile(dir: string, label: string): string | null {
|
||||
const filePath = join(dir, `${label}.md`);
|
||||
if (existsSync(filePath)) {
|
||||
return readFileSync(filePath, "utf-8");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getAttachedBlocks(): Promise<
|
||||
Array<{ id: string; label?: string; value?: string }>
|
||||
> {
|
||||
const blocks = await client.agents.blocks.list(testAgentId);
|
||||
return Array.isArray(blocks)
|
||||
? blocks
|
||||
: (
|
||||
blocks as {
|
||||
items?: Array<{ id: string; label?: string; value?: string }>;
|
||||
}
|
||||
).items || [];
|
||||
}
|
||||
|
||||
async function getOwnedBlocks(): Promise<
|
||||
Array<{ id: string; label?: string; value?: string; tags?: string[] }>
|
||||
> {
|
||||
const ownerTag = `owner:${testAgentId}`;
|
||||
const blocks = await client.blocks.list({ tags: [ownerTag] });
|
||||
return Array.isArray(blocks)
|
||||
? blocks
|
||||
: (
|
||||
blocks as {
|
||||
items?: Array<{
|
||||
id: string;
|
||||
label?: string;
|
||||
value?: string;
|
||||
tags?: string[];
|
||||
}>;
|
||||
}
|
||||
).items || [];
|
||||
}
|
||||
|
||||
test("new file in system/ creates attached block", async () => {
|
||||
const label = `test-new-file-${Date.now()}`;
|
||||
const content = "New file content";
|
||||
|
||||
// Create file in system/
|
||||
writeSystemFile(label, content);
|
||||
|
||||
// Sync
|
||||
const result = await syncMemoryFilesystem(testAgentId, {
|
||||
homeDir: tempHomeDir,
|
||||
});
|
||||
|
||||
// Verify block was created
|
||||
expect(result.createdBlocks).toContain(label);
|
||||
|
||||
// Verify block is attached
|
||||
const attachedBlocks = await getAttachedBlocks();
|
||||
const block = attachedBlocks.find((b) => b.label === label);
|
||||
expect(block).toBeDefined();
|
||||
expect(block?.value).toBe(content);
|
||||
|
||||
// Track for cleanup
|
||||
if (block?.id) {
|
||||
createdBlockIds.push(block.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("new file at root creates detached block (not attached)", async () => {
|
||||
const label = `test-detached-${Date.now()}`;
|
||||
const content = "Detached file content";
|
||||
|
||||
// Create file at root (detached)
|
||||
writeDetachedFile(label, content);
|
||||
|
||||
// Sync
|
||||
const result = await syncMemoryFilesystem(testAgentId, {
|
||||
homeDir: tempHomeDir,
|
||||
});
|
||||
|
||||
// Verify block was created
|
||||
expect(result.createdBlocks).toContain(label);
|
||||
|
||||
// Verify block is NOT attached
|
||||
const attachedBlocks = await getAttachedBlocks();
|
||||
const attachedBlock = attachedBlocks.find((b) => b.label === label);
|
||||
expect(attachedBlock).toBeUndefined();
|
||||
|
||||
// Verify block exists via owner tag (detached)
|
||||
const ownedBlocks = await getOwnedBlocks();
|
||||
const ownedBlock = ownedBlocks.find((b) => b.label === label);
|
||||
expect(ownedBlock).toBeDefined();
|
||||
expect(ownedBlock?.value).toBe(content);
|
||||
|
||||
// Track for cleanup
|
||||
if (ownedBlock?.id) {
|
||||
createdBlockIds.push(ownedBlock.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("file move from system/ to root/ detaches block (no duplication)", async () => {
|
||||
const label = `test-move-${Date.now()}`;
|
||||
const content = "Content that will be moved";
|
||||
|
||||
// Create file in system/
|
||||
writeSystemFile(label, content);
|
||||
|
||||
// First sync - creates attached block
|
||||
await syncMemoryFilesystem(testAgentId, { homeDir: tempHomeDir });
|
||||
|
||||
// Verify block is attached
|
||||
let attachedBlocks = await getAttachedBlocks();
|
||||
let block = attachedBlocks.find((b) => b.label === label);
|
||||
expect(block).toBeDefined();
|
||||
if (block?.id) {
|
||||
createdBlockIds.push(block.id);
|
||||
}
|
||||
|
||||
// Move file: delete from system/, create at root
|
||||
deleteFile(getSystemDir(), label);
|
||||
writeDetachedFile(label, content);
|
||||
|
||||
// Second sync - should detach (location mismatch with same content)
|
||||
await syncMemoryFilesystem(testAgentId, { homeDir: tempHomeDir });
|
||||
|
||||
// Verify block is no longer attached
|
||||
attachedBlocks = await getAttachedBlocks();
|
||||
block = attachedBlocks.find((b) => b.label === label);
|
||||
expect(block).toBeUndefined();
|
||||
|
||||
// Verify only ONE block exists with this label (no duplication)
|
||||
const ownedBlocks = await getOwnedBlocks();
|
||||
const matchingBlocks = ownedBlocks.filter((b) => b.label === label);
|
||||
expect(matchingBlocks.length).toBe(1);
|
||||
|
||||
// Verify the block still exists (just detached)
|
||||
expect(matchingBlocks[0]?.value).toBe(content);
|
||||
});
|
||||
|
||||
test("file deletion removes owner tag (no resurrection)", async () => {
|
||||
const label = `test-delete-${Date.now()}`;
|
||||
const content = "Content that will be deleted";
|
||||
|
||||
// Create file at root (detached)
|
||||
writeDetachedFile(label, content);
|
||||
|
||||
// First sync - creates detached block with owner tag
|
||||
await syncMemoryFilesystem(testAgentId, { homeDir: tempHomeDir });
|
||||
|
||||
// Verify block exists via owner tag
|
||||
let ownedBlocks = await getOwnedBlocks();
|
||||
let block = ownedBlocks.find((b) => b.label === label);
|
||||
expect(block).toBeDefined();
|
||||
const blockId = block?.id;
|
||||
if (blockId) {
|
||||
createdBlockIds.push(blockId);
|
||||
}
|
||||
|
||||
// Delete the file
|
||||
deleteFile(getDetachedDir(), label);
|
||||
|
||||
// Second sync - should remove owner tag
|
||||
const result = await syncMemoryFilesystem(testAgentId, {
|
||||
homeDir: tempHomeDir,
|
||||
});
|
||||
expect(result.deletedBlocks).toContain(label);
|
||||
|
||||
// Verify block no longer has owner tag (not discoverable)
|
||||
ownedBlocks = await getOwnedBlocks();
|
||||
block = ownedBlocks.find((b) => b.label === label);
|
||||
expect(block).toBeUndefined();
|
||||
|
||||
// Third sync - file should NOT resurrect
|
||||
await syncMemoryFilesystem(testAgentId, { homeDir: tempHomeDir });
|
||||
const fileContent = readFile(getDetachedDir(), label);
|
||||
expect(fileContent).toBeNull();
|
||||
});
|
||||
|
||||
test("FS wins all: when both file and block changed, file wins", async () => {
|
||||
const label = `test-fs-wins-${Date.now()}`;
|
||||
const originalContent = "Original content";
|
||||
const fileContent = "File changed content";
|
||||
const blockContent = "Block changed content";
|
||||
|
||||
// Create file in system/
|
||||
writeSystemFile(label, originalContent);
|
||||
|
||||
// First sync - creates block
|
||||
await syncMemoryFilesystem(testAgentId, { homeDir: tempHomeDir });
|
||||
|
||||
let attachedBlocks = await getAttachedBlocks();
|
||||
let block = attachedBlocks.find((b) => b.label === label);
|
||||
expect(block).toBeDefined();
|
||||
const blockId = block?.id;
|
||||
if (blockId) {
|
||||
createdBlockIds.push(blockId);
|
||||
}
|
||||
|
||||
// Change both file AND block
|
||||
writeSystemFile(label, fileContent);
|
||||
await client.blocks.update(blockId!, { value: blockContent });
|
||||
|
||||
// Second sync - file should win (no conflict)
|
||||
const result = await syncMemoryFilesystem(testAgentId, {
|
||||
homeDir: tempHomeDir,
|
||||
});
|
||||
|
||||
// Verify no conflicts
|
||||
expect(result.conflicts.length).toBe(0);
|
||||
expect(result.updatedBlocks).toContain(label);
|
||||
|
||||
// Verify block has FILE content (not block content)
|
||||
attachedBlocks = await getAttachedBlocks();
|
||||
block = attachedBlocks.find((b) => b.label === label);
|
||||
expect(block?.value).toBe(fileContent);
|
||||
});
|
||||
|
||||
test("location mismatch auto-sync: content matches but location differs", async () => {
|
||||
const label = `test-location-${Date.now()}`;
|
||||
const content = "Same content";
|
||||
|
||||
// Create file in system/
|
||||
writeSystemFile(label, content);
|
||||
|
||||
// First sync - creates attached block
|
||||
await syncMemoryFilesystem(testAgentId, { homeDir: tempHomeDir });
|
||||
|
||||
let attachedBlocks = await getAttachedBlocks();
|
||||
let block = attachedBlocks.find((b) => b.label === label);
|
||||
expect(block).toBeDefined();
|
||||
const blockId = block?.id;
|
||||
if (blockId) {
|
||||
createdBlockIds.push(blockId);
|
||||
}
|
||||
|
||||
// Move file to root (content unchanged)
|
||||
deleteFile(getSystemDir(), label);
|
||||
writeDetachedFile(label, content);
|
||||
|
||||
// Second sync - should detach block (location mismatch with same content)
|
||||
await syncMemoryFilesystem(testAgentId, { homeDir: tempHomeDir });
|
||||
|
||||
// Verify block is no longer attached
|
||||
attachedBlocks = await getAttachedBlocks();
|
||||
block = attachedBlocks.find((b) => b.label === label);
|
||||
expect(block).toBeUndefined();
|
||||
|
||||
// Verify block still exists (detached)
|
||||
const ownedBlocks = await getOwnedBlocks();
|
||||
const detachedBlock = ownedBlocks.find((b) => b.label === label);
|
||||
expect(detachedBlock).toBeDefined();
|
||||
});
|
||||
|
||||
test("location mismatch with content diff: sync both in one pass", async () => {
|
||||
const label = `test-location-content-${Date.now()}`;
|
||||
const originalContent = "Original content";
|
||||
const newContent = "New content at root";
|
||||
|
||||
// Create file in system/
|
||||
writeSystemFile(label, originalContent);
|
||||
|
||||
// First sync - creates attached block
|
||||
await syncMemoryFilesystem(testAgentId, { homeDir: tempHomeDir });
|
||||
|
||||
let attachedBlocks = await getAttachedBlocks();
|
||||
let block = attachedBlocks.find((b) => b.label === label);
|
||||
expect(block).toBeDefined();
|
||||
const blockId = block?.id;
|
||||
if (blockId) {
|
||||
createdBlockIds.push(blockId);
|
||||
}
|
||||
|
||||
// Move file to root AND change content
|
||||
deleteFile(getSystemDir(), label);
|
||||
writeDetachedFile(label, newContent);
|
||||
|
||||
// Second sync - should update content AND detach in one pass
|
||||
const result = await syncMemoryFilesystem(testAgentId, {
|
||||
homeDir: tempHomeDir,
|
||||
});
|
||||
|
||||
// Verify block content was updated
|
||||
expect(result.updatedBlocks).toContain(label);
|
||||
|
||||
// Verify block is detached
|
||||
attachedBlocks = await getAttachedBlocks();
|
||||
block = attachedBlocks.find((b) => b.label === label);
|
||||
expect(block).toBeUndefined();
|
||||
|
||||
// Verify detached block has new content
|
||||
const ownedBlocks = await getOwnedBlocks();
|
||||
const detachedBlock = ownedBlocks.find((b) => b.label === label);
|
||||
expect(detachedBlock).toBeDefined();
|
||||
expect(detachedBlock?.value).toBe(newContent);
|
||||
});
|
||||
|
||||
test("checkMemoryFilesystemStatus reports location mismatches", async () => {
|
||||
const label = `test-status-${Date.now()}`;
|
||||
const content = "Status test content";
|
||||
|
||||
// Create file in system/
|
||||
writeSystemFile(label, content);
|
||||
|
||||
// First sync - creates attached block
|
||||
await syncMemoryFilesystem(testAgentId, { homeDir: tempHomeDir });
|
||||
|
||||
const attachedBlocks = await getAttachedBlocks();
|
||||
const block = attachedBlocks.find((b) => b.label === label);
|
||||
if (block?.id) {
|
||||
createdBlockIds.push(block.id);
|
||||
}
|
||||
|
||||
// Move file to root (content unchanged)
|
||||
deleteFile(getSystemDir(), label);
|
||||
writeDetachedFile(label, content);
|
||||
|
||||
// Check status - should report location mismatch
|
||||
const status = await checkMemoryFilesystemStatus(testAgentId, {
|
||||
homeDir: tempHomeDir,
|
||||
});
|
||||
|
||||
expect(status.locationMismatches).toContain(label);
|
||||
expect(status.isClean).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -512,3 +512,190 @@ describe("block tagging", () => {
|
||||
expect(updatedBlocks.map((u) => u.blockId)).not.toContain("block-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sync behavior - location mismatch handling", () => {
|
||||
test("file move from system/ to root/ should detach block, not create duplicate", () => {
|
||||
// Bug fix test: When a file moves from system/ to root/
|
||||
//
|
||||
// Before fix:
|
||||
// 1. Sync builds detachedBlockMap from owner-tagged blocks
|
||||
// 2. System loop: "file missing in system/, block exists" → detaches block
|
||||
// 3. Detached loop: "file exists at root/, no block in detachedBlockMap"
|
||||
// → Creates NEW block (duplicate!)
|
||||
//
|
||||
// After fix:
|
||||
// 1. System loop detaches the block
|
||||
// 2. System loop adds the detached block to detachedBlockMap
|
||||
// 3. Detached loop sees both file AND block → syncs them correctly
|
||||
|
||||
// The fix ensures no duplicate blocks are created on file move
|
||||
const scenario = {
|
||||
before: {
|
||||
systemFile: "persona.md",
|
||||
attachedBlock: "persona",
|
||||
},
|
||||
action: "mv system/persona.md root/persona.md",
|
||||
after: {
|
||||
detachedFile: "persona.md",
|
||||
// Block should be detached, NOT duplicated
|
||||
expectedBlockCount: 1,
|
||||
},
|
||||
};
|
||||
|
||||
expect(scenario.after.expectedBlockCount).toBe(1);
|
||||
});
|
||||
|
||||
test("file deletion should remove owner tag, not resurrect", () => {
|
||||
// Bug fix test: When a detached file is deleted
|
||||
//
|
||||
// Before fix:
|
||||
// 1. User deletes root/notes.md
|
||||
// 2. Sync: "file missing, block exists" → untracks from state only
|
||||
// 3. Next sync: "block exists (via owner tag), file missing" → recreates file!
|
||||
//
|
||||
// After fix:
|
||||
// 1. User deletes root/notes.md
|
||||
// 2. Sync: "file missing, block exists" → removes owner tag from block
|
||||
// 3. Next sync: block no longer discovered via owner tag → file stays deleted
|
||||
|
||||
const scenario = {
|
||||
before: {
|
||||
detachedFile: "notes.md",
|
||||
detachedBlock: { id: "block-1", tags: ["owner:agent-123"] },
|
||||
},
|
||||
action: "rm root/notes.md",
|
||||
after: {
|
||||
// Block should have owner tag removed
|
||||
expectedTags: [],
|
||||
// File should NOT resurrect
|
||||
fileExists: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(scenario.after.fileExists).toBe(false);
|
||||
expect(scenario.after.expectedTags).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sync state migration", () => {
|
||||
test("legacy state format is migrated to unified format", () => {
|
||||
// The new SyncState uses blockHashes/fileHashes instead of
|
||||
// systemBlocks/systemFiles/detachedBlocks/detachedFiles
|
||||
//
|
||||
// loadSyncState should detect and migrate the legacy format
|
||||
|
||||
// Legacy format (what old state files look like):
|
||||
// {
|
||||
// systemBlocks: { persona: "hash1" },
|
||||
// systemFiles: { persona: "hash1" },
|
||||
// detachedBlocks: { notes: "hash2" },
|
||||
// detachedFiles: { notes: "hash2" },
|
||||
// detachedBlockIds: { notes: "block-123" },
|
||||
// lastSync: "2024-01-01T00:00:00.000Z",
|
||||
// }
|
||||
|
||||
// After migration, should be unified:
|
||||
const expectedMigratedState = {
|
||||
blockHashes: { persona: "hash1", notes: "hash2" },
|
||||
fileHashes: { persona: "hash1", notes: "hash2" },
|
||||
blockIds: { notes: "block-123" },
|
||||
lastSync: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
// The migration merges system + detached into unified maps
|
||||
expect(Object.keys(expectedMigratedState.blockHashes)).toHaveLength(2);
|
||||
expect(Object.keys(expectedMigratedState.fileHashes)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FS wins all policy", () => {
|
||||
test("when both file and block changed, file wins (no conflict)", () => {
|
||||
// "FS wins all" policy: if file was touched (moved or edited), file version wins
|
||||
//
|
||||
// Before this policy:
|
||||
// - Both changed → CONFLICT (agent must resolve)
|
||||
//
|
||||
// After this policy:
|
||||
// - Both changed → file wins, block is updated from file (no conflict)
|
||||
//
|
||||
// Rationale: if someone is actively working with memfs locally,
|
||||
// they're in "local mode" and FS state is their intent
|
||||
|
||||
const scenario = {
|
||||
fileChanged: true,
|
||||
blockChanged: true,
|
||||
expectedResult: "file wins, block updated",
|
||||
conflictCreated: false,
|
||||
};
|
||||
|
||||
expect(scenario.conflictCreated).toBe(false);
|
||||
expect(scenario.expectedResult).toBe("file wins, block updated");
|
||||
});
|
||||
|
||||
test("explicit resolution=block can override FS wins policy", () => {
|
||||
// Even with "FS wins all", explicit resolutions are respected
|
||||
// If user provides resolution.resolution === "block", block wins
|
||||
|
||||
const scenario = {
|
||||
fileChanged: true,
|
||||
blockChanged: true,
|
||||
resolution: { resolution: "block" },
|
||||
expectedResult: "block wins, file updated",
|
||||
};
|
||||
|
||||
expect(scenario.expectedResult).toBe("block wins, file updated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("location mismatch auto-sync", () => {
|
||||
test("content matches but location mismatches → auto-sync attachment", () => {
|
||||
// When file and block have same content but location doesn't match:
|
||||
// - File at root, block attached → detach block
|
||||
// - File in system/, block detached → attach block
|
||||
//
|
||||
// This implements "FS location is authoritative for attachment status"
|
||||
|
||||
const scenario = {
|
||||
fileLocation: "root", // file at root/
|
||||
blockAttached: true, // block is attached
|
||||
contentMatches: true,
|
||||
expectedAction: "detach block to match file location",
|
||||
};
|
||||
|
||||
expect(scenario.expectedAction).toBe("detach block to match file location");
|
||||
});
|
||||
|
||||
test("file in system/ with detached block → attach block", () => {
|
||||
const scenario = {
|
||||
fileLocation: "system",
|
||||
blockAttached: false,
|
||||
contentMatches: true,
|
||||
expectedAction: "attach block to match file location",
|
||||
};
|
||||
|
||||
expect(scenario.expectedAction).toBe("attach block to match file location");
|
||||
});
|
||||
|
||||
test("content differs AND location mismatches → sync both in one pass", () => {
|
||||
// "FS wins all" applies to both content AND location
|
||||
// When content differs and location mismatches:
|
||||
// 1. File content wins → block updated
|
||||
// 2. File location wins → attachment status synced
|
||||
// Both happen in the SAME sync (not requiring two syncs)
|
||||
|
||||
const scenario = {
|
||||
fileLocation: "root",
|
||||
blockAttached: true,
|
||||
fileContent: "new content",
|
||||
blockContent: "old content",
|
||||
expectedActions: [
|
||||
"update block content from file",
|
||||
"detach block to match file location",
|
||||
],
|
||||
requiresTwoSyncs: false, // Fixed! Previously required 2 syncs
|
||||
};
|
||||
|
||||
expect(scenario.requiresTwoSyncs).toBe(false);
|
||||
expect(scenario.expectedActions).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user