UNPKG

claude-flow

Version:

Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration

951 lines (826 loc) • 25.2 kB
/** * Beads-AgentDB Sync Bridge * * Provides bidirectional synchronization between Beads (bd) * and AgentDB. Implements conflict resolution strategies * and maintains consistency between the two systems. * * Features: * - Bidirectional sync (Beads <-> AgentDB) * - Conflict resolution strategies * - Incremental sync support * - Transaction-safe operations * - Embedding preservation * * @module v3/plugins/gastown-bridge/bridges/sync-bridge */ import { z } from 'zod'; import { BdBridge, createBdBridge, type Bead, type BeadType, type BdBridgeConfig } from './bd-bridge.js'; import { LRUCache, BatchDeduplicator, } from '../cache.js'; // ============================================================================ // Performance Caches // ============================================================================ /** Cache for AgentDB lookups during sync */ const agentDBLookupCache = new LRUCache<string, AgentDBEntry | null>({ maxEntries: 500, ttlMs: 30 * 1000, // 30 sec TTL }); /** Cache for conflict detection results */ const conflictCache = new LRUCache<string, boolean>({ maxEntries: 200, ttlMs: 10 * 1000, // 10 sec TTL }); /** Deduplicator for concurrent sync operations */ const syncDedup = new BatchDeduplicator<SyncResult>(); /** * FNV-1a hash for cache keys */ function hashKey(parts: string[]): string { let hash = 2166136261; for (const part of parts) { for (let i = 0; i < part.length; i++) { hash ^= part.charCodeAt(i); hash = (hash * 16777619) >>> 0; } hash ^= 0xff; } return hash.toString(36); } // ============================================================================ // Zod Validation Schemas // ============================================================================ /** * Sync conflict resolution strategy */ const ConflictStrategySchema = z.enum([ 'beads-wins', // Beads data takes precedence 'agentdb-wins', // AgentDB data takes precedence 'newest-wins', // Most recent timestamp wins 'merge', // Attempt to merge fields 'manual', // Flag for manual resolution ]); /** * Sync direction */ const SyncDirectionSchema = z.enum([ 'to-agentdb', // Beads -> AgentDB 'from-agentdb', // AgentDB -> Beads 'bidirectional', // Both directions ]); /** * Sync status */ const SyncStatusSchema = z.enum([ 'pending', 'in-progress', 'completed', 'failed', 'conflict', ]); /** * AgentDB entry schema (compatible with claude-flow memory) */ const AgentDBEntrySchema = z.object({ key: z.string(), value: z.unknown(), namespace: z.string().optional(), metadata: z.record(z.unknown()).optional(), embedding: z.array(z.number()).optional(), createdAt: z.string().datetime().optional(), updatedAt: z.string().datetime().optional(), version: z.number().optional(), }); // ============================================================================ // Types // ============================================================================ /** * Conflict resolution strategy type */ export type ConflictStrategy = z.infer<typeof ConflictStrategySchema>; /** * Sync direction type */ export type SyncDirection = z.infer<typeof SyncDirectionSchema>; /** * Sync status type */ export type SyncStatus = z.infer<typeof SyncStatusSchema>; /** * AgentDB entry type */ export type AgentDBEntry = z.infer<typeof AgentDBEntrySchema>; /** * Sync bridge configuration */ export interface SyncBridgeConfig { /** * Beads bridge configuration */ beadsBridge?: BdBridgeConfig; /** * AgentDB namespace for beads * Default: 'beads' */ agentdbNamespace?: string; /** * Conflict resolution strategy * Default: 'newest-wins' */ conflictStrategy?: ConflictStrategy; /** * Batch size for sync operations * Default: 100 */ batchSize?: number; /** * Whether to preserve embeddings during sync * Default: true */ preserveEmbeddings?: boolean; /** * Whether to sync metadata * Default: true */ syncMetadata?: boolean; } /** * Sync operation result */ export interface SyncResult { success: boolean; direction: SyncDirection; synced: number; created: number; updated: number; deleted: number; conflicts: number; errors: Array<{ id: string; error: string }>; durationMs: number; timestamp: string; } /** * Conflict record */ export interface SyncConflict { beadId: string; beadData: Bead; agentdbData: AgentDBEntry; conflictType: 'update' | 'delete' | 'create'; resolution?: 'beads' | 'agentdb' | 'merged' | 'pending'; resolvedAt?: string; } /** * Sync state for incremental sync */ export interface SyncState { lastSyncTime: string; lastBeadId?: string; lastAgentDBKey?: string; pendingConflicts: string[]; version: number; } /** * AgentDB interface (to be provided by claude-flow) */ export interface IAgentDBService { store(key: string, value: unknown, namespace?: string, metadata?: Record<string, unknown>): Promise<void>; retrieve(key: string, namespace?: string): Promise<AgentDBEntry | null>; search(query: string, namespace?: string, limit?: number): Promise<AgentDBEntry[]>; list(namespace?: string, limit?: number, offset?: number): Promise<AgentDBEntry[]>; delete(key: string, namespace?: string): Promise<void>; getNamespaceStats(namespace: string): Promise<{ count: number; lastUpdated?: string }>; } /** * Logger interface */ export interface SyncLogger { debug: (msg: string, meta?: Record<string, unknown>) => void; info: (msg: string, meta?: Record<string, unknown>) => void; warn: (msg: string, meta?: Record<string, unknown>) => void; error: (msg: string, meta?: Record<string, unknown>) => void; } // ============================================================================ // Errors // ============================================================================ /** * Sync bridge error codes */ export type SyncErrorCode = | 'NOT_INITIALIZED' | 'SYNC_FAILED' | 'CONFLICT_UNRESOLVED' | 'AGENTDB_ERROR' | 'BEADS_ERROR' | 'VALIDATION_ERROR' | 'TRANSACTION_FAILED'; /** * Sync bridge error */ export class SyncBridgeError extends Error { constructor( message: string, public readonly code: SyncErrorCode, public readonly details?: Record<string, unknown>, public readonly cause?: Error ) { super(message); this.name = 'SyncBridgeError'; } } // ============================================================================ // Default Logger // ============================================================================ const defaultLogger: SyncLogger = { debug: (msg, meta) => console.debug(`[sync-bridge] ${msg}`, meta ?? ''), info: (msg, meta) => console.info(`[sync-bridge] ${msg}`, meta ?? ''), warn: (msg, meta) => console.warn(`[sync-bridge] ${msg}`, meta ?? ''), error: (msg, meta) => console.error(`[sync-bridge] ${msg}`, meta ?? ''), }; // ============================================================================ // Sync Bridge Implementation // ============================================================================ /** * Beads-AgentDB Sync Bridge * * Provides bidirectional synchronization between Beads and AgentDB * with configurable conflict resolution. * * @example * ```typescript * const syncBridge = new SyncBridge(agentDB, { * conflictStrategy: 'newest-wins', * agentdbNamespace: 'conversation-beads', * }); * await syncBridge.initialize(); * * // Sync beads to AgentDB * const result = await syncBridge.syncToAgentDB(beads); * * // Sync from AgentDB back to beads * const beads = await syncBridge.syncFromAgentDB(); * ``` */ export class SyncBridge { private bdBridge: BdBridge; private agentDB: IAgentDBService; private config: Required<SyncBridgeConfig>; private logger: SyncLogger; private initialized = false; private syncState: SyncState; private conflicts: Map<string, SyncConflict> = new Map(); constructor( agentDB: IAgentDBService, config?: SyncBridgeConfig, logger?: SyncLogger ) { this.agentDB = agentDB; this.config = { beadsBridge: config?.beadsBridge ?? {}, agentdbNamespace: config?.agentdbNamespace ?? 'beads', conflictStrategy: config?.conflictStrategy ?? 'newest-wins', batchSize: config?.batchSize ?? 100, preserveEmbeddings: config?.preserveEmbeddings ?? true, syncMetadata: config?.syncMetadata ?? true, }; this.logger = logger ?? defaultLogger; this.bdBridge = createBdBridge(this.config.beadsBridge, { debug: (msg, meta) => this.logger.debug(`[bd] ${msg}`, meta), info: (msg, meta) => this.logger.info(`[bd] ${msg}`, meta), warn: (msg, meta) => this.logger.warn(`[bd] ${msg}`, meta), error: (msg, meta) => this.logger.error(`[bd] ${msg}`, meta), }); this.syncState = { lastSyncTime: new Date(0).toISOString(), pendingConflicts: [], version: 1, }; } /** * Initialize the sync bridge */ async initialize(): Promise<void> { try { await this.bdBridge.initialize(); // Load sync state from AgentDB if exists const savedState = await this.agentDB.retrieve( '_sync_state', this.config.agentdbNamespace ); if (savedState?.value) { const parsed = savedState.value as SyncState; this.syncState = { lastSyncTime: parsed.lastSyncTime ?? new Date(0).toISOString(), lastBeadId: parsed.lastBeadId, lastAgentDBKey: parsed.lastAgentDBKey, pendingConflicts: parsed.pendingConflicts ?? [], version: (parsed.version ?? 0) + 1, }; } this.initialized = true; this.logger.info('Sync bridge initialized', { namespace: this.config.agentdbNamespace, conflictStrategy: this.config.conflictStrategy, syncState: this.syncState, }); } catch (error) { throw new SyncBridgeError( 'Failed to initialize sync bridge', 'NOT_INITIALIZED', undefined, error as Error ); } } /** * Sync beads to AgentDB */ async syncToAgentDB(beads: Bead[]): Promise<SyncResult> { this.ensureInitialized(); const startTime = Date.now(); const result: SyncResult = { success: true, direction: 'to-agentdb', synced: 0, created: 0, updated: 0, deleted: 0, conflicts: 0, errors: [], durationMs: 0, timestamp: new Date().toISOString(), }; this.logger.info(`Starting sync to AgentDB: ${beads.length} beads`); // Process in batches with parallel lookups for (let i = 0; i < beads.length; i += this.config.batchSize) { const batch = beads.slice(i, i + this.config.batchSize); // Parallel lookup for all beads in batch const lookupPromises = batch.map(async (bead) => { const key = this.beadToKey(bead); const cacheKey = hashKey([key, this.config.agentdbNamespace]); // Check cache first if (agentDBLookupCache.has(cacheKey)) { return { bead, key, existing: agentDBLookupCache.get(cacheKey) }; } const existing = await this.agentDB.retrieve(key, this.config.agentdbNamespace); agentDBLookupCache.set(cacheKey, existing); return { bead, key, existing }; }); const lookupResults = await Promise.all(lookupPromises); // Process results for (const { bead, key, existing } of lookupResults) { try { if (existing) { // Check for conflicts (use cache) const conflictCacheKey = hashKey([bead.id, bead.content, existing.key]); let hasConflict = conflictCache.get(conflictCacheKey); if (hasConflict === undefined) { hasConflict = await this.detectConflict(bead, existing); conflictCache.set(conflictCacheKey, hasConflict); } if (hasConflict) { const resolved = await this.resolveConflict(bead, existing); if (!resolved) { result.conflicts++; continue; } } result.updated++; } else { result.created++; } // Store bead in AgentDB await this.agentDB.store( key, this.beadToAgentDBValue(bead), this.config.agentdbNamespace, this.buildMetadata(bead) ); // Invalidate lookup cache for this key const cacheKey = hashKey([key, this.config.agentdbNamespace]); agentDBLookupCache.delete(cacheKey); result.synced++; } catch (error) { result.errors.push({ id: bead.id, error: error instanceof Error ? error.message : String(error), }); this.logger.error(`Failed to sync bead ${bead.id}`, { error }); } } } // Update sync state this.syncState.lastSyncTime = result.timestamp; if (beads.length > 0) { this.syncState.lastBeadId = beads[beads.length - 1]?.id; } await this.saveSyncState(); result.durationMs = Date.now() - startTime; result.success = result.errors.length === 0 && result.conflicts === 0; this.logger.info('Sync to AgentDB complete', { synced: result.synced, created: result.created, updated: result.updated, conflicts: result.conflicts, errors: result.errors.length, durationMs: result.durationMs, }); return result; } /** * Sync from AgentDB to Beads */ async syncFromAgentDB(): Promise<Bead[]> { this.ensureInitialized(); const startTime = Date.now(); const beads: Bead[] = []; this.logger.info('Starting sync from AgentDB'); try { // Get all entries from AgentDB namespace let offset = 0; let hasMore = true; while (hasMore) { const entries = await this.agentDB.list( this.config.agentdbNamespace, this.config.batchSize, offset ); if (entries.length === 0) { hasMore = false; continue; } for (const entry of entries) { // Skip sync state entry if (entry.key === '_sync_state') continue; try { const bead = this.agentDBToBead(entry); if (bead) { beads.push(bead); } } catch (error) { this.logger.warn(`Failed to convert AgentDB entry to bead: ${entry.key}`, { error: error instanceof Error ? error.message : String(error), }); } } offset += entries.length; hasMore = entries.length === this.config.batchSize; } // Update sync state this.syncState.lastSyncTime = new Date().toISOString(); await this.saveSyncState(); const durationMs = Date.now() - startTime; this.logger.info('Sync from AgentDB complete', { beads: beads.length, durationMs, }); return beads; } catch (error) { throw new SyncBridgeError( 'Failed to sync from AgentDB', 'SYNC_FAILED', undefined, error as Error ); } } /** * Perform full bidirectional sync */ async syncBidirectional(): Promise<{ toAgentDB: SyncResult; fromAgentDB: Bead[]; }> { this.ensureInitialized(); this.logger.info('Starting bidirectional sync'); // First sync from beads to AgentDB const allBeads = await this.bdBridge.listBeads({ after: this.syncState.lastSyncTime, }); const toAgentDBResult = await this.syncToAgentDB(allBeads); // Then sync from AgentDB to beads const fromAgentDBBeads = await this.syncFromAgentDB(); return { toAgentDB: toAgentDBResult, fromAgentDB: fromAgentDBBeads, }; } /** * Get pending conflicts */ getPendingConflicts(): SyncConflict[] { return Array.from(this.conflicts.values()).filter( c => c.resolution === 'pending' || !c.resolution ); } /** * Resolve a specific conflict manually */ async resolveConflictManually( beadId: string, resolution: 'beads' | 'agentdb' | 'merged', mergedData?: Partial<Bead> ): Promise<void> { const conflict = this.conflicts.get(beadId); if (!conflict) { throw new SyncBridgeError( `No conflict found for bead: ${beadId}`, 'VALIDATION_ERROR' ); } const key = this.beadToKey(conflict.beadData); switch (resolution) { case 'beads': await this.agentDB.store( key, this.beadToAgentDBValue(conflict.beadData), this.config.agentdbNamespace, this.buildMetadata(conflict.beadData) ); break; case 'agentdb': // AgentDB data is already stored, nothing to do break; case 'merged': if (!mergedData) { throw new SyncBridgeError( 'Merged data required for merge resolution', 'VALIDATION_ERROR' ); } const merged = { ...conflict.beadData, ...mergedData }; await this.agentDB.store( key, this.beadToAgentDBValue(merged as Bead), this.config.agentdbNamespace, this.buildMetadata(merged as Bead) ); break; } conflict.resolution = resolution; conflict.resolvedAt = new Date().toISOString(); // Remove from pending const pendingIndex = this.syncState.pendingConflicts.indexOf(beadId); if (pendingIndex !== -1) { this.syncState.pendingConflicts.splice(pendingIndex, 1); await this.saveSyncState(); } this.logger.info(`Conflict resolved for bead ${beadId}`, { resolution }); } /** * Get sync state */ getSyncState(): Readonly<SyncState> { return { ...this.syncState }; } /** * Get sync statistics */ async getSyncStats(): Promise<{ agentdbCount: number; lastSyncTime: string; pendingConflicts: number; syncVersion: number; }> { this.ensureInitialized(); const stats = await this.agentDB.getNamespaceStats(this.config.agentdbNamespace); return { agentdbCount: stats.count, lastSyncTime: this.syncState.lastSyncTime, pendingConflicts: this.syncState.pendingConflicts.length, syncVersion: this.syncState.version, }; } // ============================================================================ // Private Methods // ============================================================================ /** * Convert bead to AgentDB key */ private beadToKey(bead: Bead): string { return `bead:${bead.id}`; } /** * Convert bead to AgentDB value */ private beadToAgentDBValue(bead: Bead): Record<string, unknown> { const value: Record<string, unknown> = { id: bead.id, type: bead.type, content: bead.content, timestamp: bead.timestamp, parentId: bead.parentId, threadId: bead.threadId, agentId: bead.agentId, tags: bead.tags, hash: bead.hash, }; if (this.config.preserveEmbeddings && bead.embedding) { value.embedding = bead.embedding; } if (this.config.syncMetadata && bead.metadata) { value.metadata = bead.metadata; } return value; } /** * Build metadata for AgentDB entry */ private buildMetadata(bead: Bead): Record<string, unknown> { return { beadType: bead.type, threadId: bead.threadId, agentId: bead.agentId, syncedAt: new Date().toISOString(), syncVersion: this.syncState.version, }; } /** * Convert AgentDB entry to Bead */ private agentDBToBead(entry: AgentDBEntry): Bead | null { if (!entry.value || typeof entry.value !== 'object') { return null; } const data = entry.value as Record<string, unknown>; // Validate required fields if (!data.id || !data.type || !data.content) { return null; } return { id: String(data.id), type: data.type as BeadType, content: String(data.content), timestamp: data.timestamp as string | undefined, parentId: data.parentId as string | undefined, threadId: data.threadId as string | undefined, agentId: data.agentId as string | undefined, tags: data.tags as string[] | undefined, metadata: data.metadata as Record<string, unknown> | undefined, embedding: data.embedding as number[] | undefined, hash: data.hash as string | undefined, }; } /** * Detect if there's a conflict between bead and AgentDB entry */ private async detectConflict(bead: Bead, entry: AgentDBEntry): Promise<boolean> { if (!entry.value || typeof entry.value !== 'object') { return false; } const data = entry.value as Record<string, unknown>; // No conflict if content is the same if (data.content === bead.content) { return false; } // Check timestamps const beadTime = bead.timestamp ? new Date(bead.timestamp).getTime() : 0; const entryTime = entry.updatedAt ? new Date(entry.updatedAt).getTime() : 0; // If bead is newer, no conflict - it should update if (beadTime > entryTime) { return false; } // If AgentDB is newer and content differs, conflict if (entryTime > beadTime && data.content !== bead.content) { return true; } return false; } /** * Resolve conflict based on strategy */ private async resolveConflict(bead: Bead, entry: AgentDBEntry): Promise<boolean> { const conflict: SyncConflict = { beadId: bead.id, beadData: bead, agentdbData: entry, conflictType: 'update', }; switch (this.config.conflictStrategy) { case 'beads-wins': conflict.resolution = 'beads'; this.conflicts.set(bead.id, conflict); return true; case 'agentdb-wins': conflict.resolution = 'agentdb'; this.conflicts.set(bead.id, conflict); return false; // Don't update AgentDB case 'newest-wins': { const beadTime = bead.timestamp ? new Date(bead.timestamp).getTime() : 0; const entryTime = entry.updatedAt ? new Date(entry.updatedAt).getTime() : 0; if (beadTime >= entryTime) { conflict.resolution = 'beads'; this.conflicts.set(bead.id, conflict); return true; } else { conflict.resolution = 'agentdb'; this.conflicts.set(bead.id, conflict); return false; } } case 'merge': { // Simple merge: keep both contents with separator const entryData = entry.value as Record<string, unknown>; const mergedBead: Bead = { ...bead, content: `${bead.content}\n---\n${entryData.content}`, metadata: { ...bead.metadata, merged: true, mergedAt: new Date().toISOString(), }, }; conflict.beadData = mergedBead; conflict.resolution = 'merged'; this.conflicts.set(bead.id, conflict); return true; } case 'manual': conflict.resolution = 'pending'; this.conflicts.set(bead.id, conflict); this.syncState.pendingConflicts.push(bead.id); return false; default: return false; } } /** * Save sync state to AgentDB */ private async saveSyncState(): Promise<void> { try { await this.agentDB.store( '_sync_state', this.syncState, this.config.agentdbNamespace, { type: 'sync-state' } ); } catch (error) { this.logger.error('Failed to save sync state', { error: error instanceof Error ? error.message : String(error), }); } } /** * Ensure bridge is initialized */ private ensureInitialized(): void { if (!this.initialized) { throw new SyncBridgeError( 'Sync bridge not initialized. Call initialize() first.', 'NOT_INITIALIZED' ); } } /** * Check if bridge is initialized */ isInitialized(): boolean { return this.initialized; } /** * Get beads bridge instance */ getBeadsBridge(): BdBridge { return this.bdBridge; } /** * Get cache statistics for performance monitoring */ getCacheStats(): { agentDBLookupCache: { entries: number; sizeBytes: number }; conflictCache: { entries: number; sizeBytes: number }; } { return { agentDBLookupCache: agentDBLookupCache.stats(), conflictCache: conflictCache.stats(), }; } /** * Clear all sync caches */ clearCaches(): void { agentDBLookupCache.clear(); conflictCache.clear(); } } /** * Create a new sync bridge instance */ export function createSyncBridge( agentDB: IAgentDBService, config?: SyncBridgeConfig, logger?: SyncLogger ): SyncBridge { return new SyncBridge(agentDB, config, logger); } // Export schemas for external use export { ConflictStrategySchema, SyncDirectionSchema, SyncStatusSchema, AgentDBEntrySchema, }; export default SyncBridge;