UNPKG

@stackmemoryai/stackmemory

Version:

Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, a

749 lines (748 loc) 24.6 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { logger } from "../monitoring/logger.js"; import { ValidationError, DatabaseError, ErrorCode } from "../errors/index.js"; import { validateInput, StartMergeSessionSchema, CreateMergePolicySchema, ConflictResolutionSchema } from "./validation.js"; class StackMergeResolver { dualStackManager; activeSessions = /* @__PURE__ */ new Map(); mergePolicies = /* @__PURE__ */ new Map(); constructor(dualStackManager) { this.dualStackManager = dualStackManager; this.initializeDefaultPolicies(); logger.debug("StackMergeResolver initialized", { policies: Array.from(this.mergePolicies.keys()) }); } /** * Start a merge session with conflict analysis */ async startMergeSession(sourceStackId, targetStackId, frameIds, policyName = "default") { const input = validateInput(StartMergeSessionSchema, { sourceStackId, targetStackId, frameIds, policyName }); const sessionId = `merge-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; logger.debug("Looking for merge policy", { policyName: input.policyName, availablePolicies: Array.from(this.mergePolicies.keys()) }); const policy = this.mergePolicies.get(input.policyName); if (!policy) { logger.error("Merge policy not found", { requested: input.policyName, available: Array.from(this.mergePolicies.keys()) }); throw new ValidationError( `Merge policy not found: ${input.policyName}`, ErrorCode.RESOURCE_NOT_FOUND ); } try { const currentUserId = this.dualStackManager.getCurrentContext().ownerId || "unknown"; await this.dualStackManager.getPermissionManager().enforcePermission( this.dualStackManager.getPermissionManager().createContext(currentUserId, "merge", "stack", input.sourceStackId) ); await this.dualStackManager.getPermissionManager().enforcePermission( this.dualStackManager.getPermissionManager().createContext(currentUserId, "merge", "stack", input.targetStackId) ); const session = { sessionId, sourceStackId: input.sourceStackId, targetStackId: input.targetStackId, conflicts: [], resolutions: [], policy, status: "analyzing", startedAt: /* @__PURE__ */ new Date(), metadata: { totalFrames: 0, conflictFrames: 0, autoResolvedConflicts: 0, manualResolvedConflicts: 0 } }; this.activeSessions.set(sessionId, session); await this.analyzeConflicts(sessionId, frameIds); await this.autoResolveConflicts(sessionId); logger.info(`Merge session started: ${sessionId}`, { sourceStack: sourceStackId, targetStack: targetStackId, conflicts: session.conflicts.length, policy: policyName }); return sessionId; } catch (error) { logger.error("Failed to start merge session", { error: error instanceof Error ? error.message : error, sourceStackId: input.sourceStackId, targetStackId: input.targetStackId, policyName: input.policyName }); throw new DatabaseError( "Failed to start merge session", ErrorCode.OPERATION_FAILED, { sourceStackId, targetStackId }, error instanceof Error ? error : void 0 ); } } /** * Analyze conflicts between source and target stacks */ async analyzeConflicts(sessionId, frameIds) { const session = this.activeSessions.get(sessionId); if (!session) { throw new DatabaseError( `Merge session not found: ${sessionId}`, ErrorCode.RESOURCE_NOT_FOUND ); } try { const sourceStack = this.getStackManager(session.sourceStackId); const targetStack = this.getStackManager(session.targetStackId); const framesToAnalyze = frameIds || (await sourceStack.getActiveFrames()).map((f) => f.frame_id); session.metadata.totalFrames = framesToAnalyze.length; for (const frameId of framesToAnalyze) { const sourceFrame = await sourceStack.getFrame(frameId); if (!sourceFrame) continue; const targetFrame = await targetStack.getFrame(frameId); if (!targetFrame) continue; const conflicts = await this.analyzeFrameConflicts( sourceFrame, targetFrame ); session.conflicts.push(...conflicts); } session.metadata.conflictFrames = new Set( session.conflicts.map((c) => c.frameId) ).size; session.status = "resolving"; this.activeSessions.set(sessionId, session); logger.info(`Conflict analysis completed: ${sessionId}`, { totalConflicts: session.conflicts.length, conflictFrames: session.metadata.conflictFrames }); } catch (error) { session.status = "failed"; this.activeSessions.set(sessionId, session); throw error; } } /** * Analyze conflicts within a single frame */ async analyzeFrameConflicts(sourceFrame, targetFrame) { const conflicts = []; if (sourceFrame.name !== targetFrame.name) { conflicts.push({ frameId: sourceFrame.frame_id, conflictType: "content", sourceFrame, targetFrame, conflictDetails: [ { field: "name", sourceValue: sourceFrame.name, targetValue: targetFrame.name, lastModified: { source: new Date(sourceFrame.created_at * 1e3), target: new Date(targetFrame.created_at * 1e3) } } ], severity: "medium", autoResolvable: false }); } if (sourceFrame.state !== targetFrame.state) { conflicts.push({ frameId: sourceFrame.frame_id, conflictType: "metadata", sourceFrame, targetFrame, conflictDetails: [ { field: "state", sourceValue: sourceFrame.state, targetValue: targetFrame.state, lastModified: { source: new Date(sourceFrame.created_at * 1e3), target: new Date(targetFrame.created_at * 1e3) } } ], severity: "high", autoResolvable: true // Can auto-resolve based on timestamps }); } if (JSON.stringify(sourceFrame.inputs) !== JSON.stringify(targetFrame.inputs)) { conflicts.push({ frameId: sourceFrame.frame_id, conflictType: "content", sourceFrame, targetFrame, conflictDetails: [ { field: "inputs", sourceValue: sourceFrame.inputs, targetValue: targetFrame.inputs, lastModified: { source: new Date(sourceFrame.created_at * 1e3), target: new Date(targetFrame.created_at * 1e3) } } ], severity: "medium", autoResolvable: false }); } const eventConflicts = await this.analyzeEventConflicts( sourceFrame, targetFrame ); conflicts.push(...eventConflicts); const anchorConflicts = await this.analyzeAnchorConflicts( sourceFrame, targetFrame ); conflicts.push(...anchorConflicts); return conflicts; } /** * Analyze conflicts in frame events */ async analyzeEventConflicts(sourceFrame, targetFrame) { const conflicts = []; try { const sourceStack = this.getStackManager(sourceFrame.project_id); const targetStack = this.getStackManager(targetFrame.project_id); const sourceEvents = await sourceStack.getFrameEvents( sourceFrame.frame_id ); const targetEvents = await targetStack.getFrameEvents( targetFrame.frame_id ); if (sourceEvents.length !== targetEvents.length) { conflicts.push({ frameId: sourceFrame.frame_id, conflictType: "sequence", sourceFrame, targetFrame, conflictDetails: [ { field: "event_count", sourceValue: sourceEvents.length, targetValue: targetEvents.length, lastModified: { source: /* @__PURE__ */ new Date(), target: /* @__PURE__ */ new Date() } } ], severity: "high", autoResolvable: true // Can merge events }); } const minLength = Math.min(sourceEvents.length, targetEvents.length); for (let i = 0; i < minLength; i++) { const sourceEvent = sourceEvents[i]; const targetEvent = targetEvents[i]; if (sourceEvent.text !== targetEvent.text || JSON.stringify(sourceEvent.metadata) !== JSON.stringify(targetEvent.metadata)) { conflicts.push({ frameId: sourceFrame.frame_id, conflictType: "content", sourceFrame, targetFrame, conflictDetails: [ { field: `event_${i}`, sourceValue: { text: sourceEvent.text, metadata: sourceEvent.metadata }, targetValue: { text: targetEvent.text, metadata: targetEvent.metadata }, lastModified: { source: /* @__PURE__ */ new Date(), target: /* @__PURE__ */ new Date() } } ], severity: "medium", autoResolvable: false }); } } } catch (error) { logger.warn( `Failed to analyze event conflicts for frame: ${sourceFrame.frame_id}`, error ); } return conflicts; } /** * Analyze conflicts in frame anchors */ async analyzeAnchorConflicts(sourceFrame, targetFrame) { const conflicts = []; try { const sourceStack = this.getStackManager(sourceFrame.project_id); const targetStack = this.getStackManager(targetFrame.project_id); const sourceAnchors = await sourceStack.getFrameAnchors( sourceFrame.frame_id ); const targetAnchors = await targetStack.getFrameAnchors( targetFrame.frame_id ); const sourceAnchorsByType = this.groupAnchorsByType(sourceAnchors); const targetAnchorsByType = this.groupAnchorsByType(targetAnchors); const allTypes = /* @__PURE__ */ new Set([ ...Object.keys(sourceAnchorsByType), ...Object.keys(targetAnchorsByType) ]); for (const type of allTypes) { const sourceTypeAnchors = sourceAnchorsByType[type] || []; const targetTypeAnchors = targetAnchorsByType[type] || []; if (sourceTypeAnchors.length !== targetTypeAnchors.length || !this.anchorsEqual(sourceTypeAnchors, targetTypeAnchors)) { conflicts.push({ frameId: sourceFrame.frame_id, conflictType: "content", sourceFrame, targetFrame, conflictDetails: [ { field: `anchors_${type}`, sourceValue: sourceTypeAnchors, targetValue: targetTypeAnchors, lastModified: { source: /* @__PURE__ */ new Date(), target: /* @__PURE__ */ new Date() } } ], severity: "low", autoResolvable: true // Can merge anchors }); } } } catch (error) { logger.warn( `Failed to analyze anchor conflicts for frame: ${sourceFrame.frame_id}`, error ); } return conflicts; } /** * Auto-resolve conflicts based on merge policy */ async autoResolveConflicts(sessionId) { const session = this.activeSessions.get(sessionId); if (!session) return; const autoResolvableConflicts = session.conflicts.filter( (c) => c.autoResolvable ); for (const conflict of autoResolvableConflicts) { const resolution = await this.applyMergePolicy(conflict, session.policy); if (resolution) { session.resolutions.push(resolution); session.metadata.autoResolvedConflicts++; logger.debug(`Auto-resolved conflict: ${conflict.frameId}`, { type: conflict.conflictType, strategy: resolution.strategy }); } } const remainingConflicts = session.conflicts.filter( (c) => !session.resolutions.find((r) => r.conflictId === c.frameId) ); if (remainingConflicts.length === 0) { session.status = "completed"; session.completedAt = /* @__PURE__ */ new Date(); } else if (remainingConflicts.every((c) => !c.autoResolvable)) { session.status = "manual_review"; } this.activeSessions.set(sessionId, session); } /** * Apply merge policy to resolve conflicts automatically */ async applyMergePolicy(conflict, policy) { const sortedRules = policy.rules.sort((a, b) => b.priority - a.priority); for (const rule of sortedRules) { if (this.evaluateRuleCondition(conflict, rule.condition)) { return { conflictId: conflict.frameId, strategy: rule.action === "require_manual" ? "manual" : rule.action, resolvedBy: "system", resolvedAt: /* @__PURE__ */ new Date(), notes: `Auto-resolved by policy: ${policy.name}` }; } } return null; } /** * Manually resolve a specific conflict */ async resolveConflict(sessionId, conflictId, resolution) { const input = validateInput(ConflictResolutionSchema, { strategy: resolution.strategy, resolvedBy: resolution.resolvedBy, notes: resolution.notes }); const session = this.activeSessions.get(sessionId); if (!session) { throw new ValidationError( `Merge session not found: ${sessionId}`, ErrorCode.MERGE_SESSION_INVALID ); } const conflict = session.conflicts.find((c) => c.frameId === conflictId); if (!conflict) { throw new ValidationError( `Conflict not found: ${conflictId}`, ErrorCode.MERGE_CONFLICT_UNRESOLVABLE ); } const fullResolution = { ...input, conflictId, resolvedAt: /* @__PURE__ */ new Date() }; session.resolutions.push(fullResolution); session.metadata.manualResolvedConflicts++; const resolvedConflictIds = new Set( session.resolutions.map((r) => r.conflictId) ); const allResolved = session.conflicts.every( (c) => resolvedConflictIds.has(c.frameId) ); if (allResolved) { session.status = "completed"; session.completedAt = /* @__PURE__ */ new Date(); } this.activeSessions.set(sessionId, session); logger.info(`Conflict manually resolved: ${conflictId}`, { strategy: resolution.strategy, resolvedBy: resolution.resolvedBy }); } /** * Execute merge with resolved conflicts */ async executeMerge(sessionId) { const session = this.activeSessions.get(sessionId); if (!session) { throw new DatabaseError( `Merge session not found: ${sessionId}`, ErrorCode.RESOURCE_NOT_FOUND ); } if (session.status !== "completed") { throw new DatabaseError( `Merge session not ready for execution: ${session.status}`, ErrorCode.INVALID_STATE ); } try { const resolutionMap = new Map( session.resolutions.map((r) => [r.conflictId, r]) ); const result = { success: true, conflictFrames: [], mergedFrames: [], errors: [] }; const sourceStack = this.getStackManager(session.sourceStackId); const targetStack = this.getStackManager(session.targetStackId); const conflictsByFrame = /* @__PURE__ */ new Map(); for (const conflict of session.conflicts) { const existing = conflictsByFrame.get(conflict.frameId) || []; existing.push(conflict); conflictsByFrame.set(conflict.frameId, existing); } for (const [frameId] of conflictsByFrame) { try { const resolution = resolutionMap.get(frameId); if (!resolution) { result.errors.push({ frameId, error: "No resolution found", resolution: "skipped" }); result.conflictFrames.push(frameId); continue; } const sourceFrame = await sourceStack.getFrame(frameId); const targetFrame = await targetStack.getFrame(frameId); if (!sourceFrame) { result.errors.push({ frameId, error: "Source frame not found", resolution: "skipped" }); continue; } switch (resolution.strategy) { case "source_wins": if (targetFrame) await targetStack.deleteFrame(frameId); await this.copyFrameToStack( sourceFrame, sourceStack, targetStack ); result.mergedFrames.push(frameId); break; case "target_wins": result.mergedFrames.push(frameId); break; case "merge_both": if (targetFrame) { await this.mergeFrameContents( sourceFrame, targetFrame, sourceStack, targetStack ); } else { await this.copyFrameToStack( sourceFrame, sourceStack, targetStack ); } result.mergedFrames.push(frameId); break; case "skip": result.conflictFrames.push(frameId); break; case "manual": result.errors.push({ frameId, error: "Manual resolution not applied", resolution: "skipped" }); result.conflictFrames.push(frameId); break; } } catch (error) { result.errors.push({ frameId, error: error instanceof Error ? error.message : String(error), resolution: "skipped" }); result.success = false; } } logger.info(`Merge executed: ${sessionId}`, { mergedFrames: result.mergedFrames.length, conflicts: result.conflictFrames.length, errors: result.errors.length }); return result; } catch (error) { throw new DatabaseError( "Failed to execute merge", ErrorCode.OPERATION_FAILED, { sessionId }, error instanceof Error ? error : void 0 ); } } async copyFrameToStack(frame, sourceStack, targetStack) { await targetStack.createFrame({ frame_id: frame.frame_id, name: frame.name, type: frame.type, parent_frame_id: frame.parent_frame_id, inputs: frame.inputs, outputs: frame.outputs }); const events = await sourceStack.getFrameEvents(frame.frame_id); for (const event of events) { await targetStack.addEvent(frame.frame_id, { type: event.type, text: event.text, metadata: event.metadata }); } const anchors = await sourceStack.getFrameAnchors(frame.frame_id); for (const anchor of anchors) { await targetStack.addAnchor(frame.frame_id, { type: anchor.type, text: anchor.text, priority: anchor.priority, metadata: anchor.metadata }); } } async mergeFrameContents(sourceFrame, targetFrame, sourceStack, targetStack) { const sourceEvents = await sourceStack.getFrameEvents(sourceFrame.frame_id); const targetEvents = await targetStack.getFrameEvents(targetFrame.frame_id); const existingSignatures = new Set( targetEvents.map((e) => `${e.type}:${e.text}`) ); for (const event of sourceEvents) { const sig = `${event.type}:${event.text}`; if (!existingSignatures.has(sig)) { await targetStack.addEvent(targetFrame.frame_id, { type: event.type, text: event.text, metadata: { ...event.metadata, merged: true } }); existingSignatures.add(sig); } } const sourceAnchors = await sourceStack.getFrameAnchors( sourceFrame.frame_id ); const targetAnchors = await targetStack.getFrameAnchors( targetFrame.frame_id ); const existingAnchorSigs = new Set( targetAnchors.map((a) => `${a.type}:${a.text}:${a.priority}`) ); for (const anchor of sourceAnchors) { const sig = `${anchor.type}:${anchor.text}:${anchor.priority}`; if (!existingAnchorSigs.has(sig)) { await targetStack.addAnchor(targetFrame.frame_id, { type: anchor.type, text: anchor.text, priority: anchor.priority, metadata: { ...anchor.metadata, merged: true } }); existingAnchorSigs.add(sig); } } logger.debug(`Merged frame contents: ${sourceFrame.frame_id}`); } /** * Get merge session details */ async getMergeSession(sessionId) { return this.activeSessions.get(sessionId) || null; } /** * Create custom merge policy */ async createMergePolicy(policy) { const input = validateInput(CreateMergePolicySchema, policy); this.mergePolicies.set(input.name, input); logger.info(`Created merge policy: ${input.name}`, { rules: input.rules.length, autoApplyThreshold: input.autoApplyThreshold }); } /** * Initialize default merge policies */ initializeDefaultPolicies() { this.mergePolicies.set("conservative", { name: "conservative", description: "Prefer manual resolution for most conflicts", rules: [ { condition: '$.conflictType == "metadata" && $.severity == "low"', action: "target_wins", priority: 1 }, { condition: '$.severity == "critical"', action: "require_manual", priority: 10 } ], autoApplyThreshold: "never" }); this.mergePolicies.set("aggressive", { name: "aggressive", description: "Auto-resolve conflicts when safe", rules: [ { condition: '$.conflictType == "sequence"', action: "merge_both", priority: 5 }, { condition: '$.severity == "low"', action: "source_wins", priority: 2 }, { condition: '$.severity == "medium" && $.autoResolvable', action: "merge_both", priority: 4 } ], autoApplyThreshold: "medium" }); this.mergePolicies.set("default", { name: "default", description: "Balanced conflict resolution", rules: [ { condition: '$.conflictType == "sequence" && $.severity == "low"', action: "merge_both", priority: 3 }, { condition: '$.conflictType == "metadata" && $.autoResolvable', action: "target_wins", priority: 2 }, { condition: '$.severity == "critical"', action: "require_manual", priority: 10 } ], autoApplyThreshold: "low" }); } // Helper methods getStackManager(stackId) { return this.dualStackManager.getStackManager(stackId); } groupAnchorsByType(anchors) { return anchors.reduce( (groups, anchor) => { if (!groups[anchor.type]) groups[anchor.type] = []; groups[anchor.type].push(anchor); return groups; }, {} ); } anchorsEqual(anchors1, anchors2) { if (anchors1.length !== anchors2.length) return false; const sorted1 = [...anchors1].sort((a, b) => a.text.localeCompare(b.text)); const sorted2 = [...anchors2].sort((a, b) => a.text.localeCompare(b.text)); return sorted1.every( (anchor, i) => anchor.text === sorted2[i].text && anchor.priority === sorted2[i].priority ); } evaluateRuleCondition(conflict, condition) { return condition.includes(conflict.conflictType) || condition.includes(conflict.severity); } } export { StackMergeResolver };