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

236 lines (235 loc) 6.96 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { EventEmitter } from "events"; import { logger } from "../../core/monitoring/logger.js"; import { LinearSyncEngine } from "./sync.js"; import { AsyncMutex } from "../../core/utils/async-mutex.js"; class LinearSyncManager extends EventEmitter { syncEngine; syncTimer; pendingSyncTimer; config; lastSyncTime = 0; syncMutex; taskStore; constructor(taskStore, authManager, config, projectRoot) { super(); this.taskStore = taskStore; this.syncMutex = new AsyncMutex(3e5); this.config = { ...config, autoSyncInterval: config.autoSyncInterval || 15, syncOnTaskChange: config.syncOnTaskChange !== false, syncOnSessionStart: config.syncOnSessionStart !== false, syncOnSessionEnd: config.syncOnSessionEnd !== false, debounceInterval: config.debounceInterval || 5e3 // 5 seconds }; this.syncEngine = new LinearSyncEngine( taskStore, authManager, config, projectRoot ); this.setupEventListeners(); this.setupPeriodicSync(); } /** * Setup event listeners for automatic sync triggers */ setupEventListeners() { if (this.config.syncOnTaskChange && this.taskStore) { this.taskStore.on("sync:needed", (changeType) => { logger.debug(`Task change detected: ${changeType}`); this.scheduleDebouncedSync(); }); this.taskStore.on("task:created", (task) => { logger.debug(`Task created: ${task.title}`); }); this.taskStore.on("task:completed", (task) => { logger.debug(`Task completed: ${task.title}`); }); logger.info("Task change sync enabled via EventEmitter"); } } /** * Setup periodic sync timer */ setupPeriodicSync() { if (!this.config.autoSync || !this.config.autoSyncInterval) { return; } if (this.syncTimer) { clearInterval(this.syncTimer); } const intervalMs = this.config.autoSyncInterval * 60 * 1e3; this.syncTimer = setInterval(() => { this.performSync("periodic"); }, intervalMs); logger.info( `Periodic Linear sync enabled: every ${this.config.autoSyncInterval} minutes` ); } /** * Schedule a debounced sync to avoid too frequent syncs */ scheduleDebouncedSync() { if (!this.config.enabled) return; if (this.pendingSyncTimer) { clearTimeout(this.pendingSyncTimer); } this.pendingSyncTimer = setTimeout(() => { this.performSync("task-change"); }, this.config.debounceInterval); } /** * Perform a sync operation * Uses mutex to prevent concurrent sync operations (thread-safe) */ async performSync(trigger) { if (!this.config.enabled) { return { success: false, synced: { toLinear: 0, fromLinear: 0, updated: 0 }, conflicts: [], errors: ["Sync is disabled"] }; } const release = this.syncMutex.tryAcquire(`linear-sync-${trigger}`); if (!release) { logger.warn(`Linear sync already in progress, skipping ${trigger} sync`); return { success: false, synced: { toLinear: 0, fromLinear: 0, updated: 0 }, conflicts: [], errors: ["Sync already in progress"] }; } try { const now = Date.now(); const timeSinceLastSync = now - this.lastSyncTime; const minInterval = 1e4; if (trigger !== "manual" && timeSinceLastSync < minInterval) { logger.debug( `Skipping ${trigger} sync, too soon since last sync (${timeSinceLastSync}ms ago)` ); return { success: false, synced: { toLinear: 0, fromLinear: 0, updated: 0 }, conflicts: [], errors: [ `Too soon since last sync (wait ${minInterval - timeSinceLastSync}ms)` ] }; } this.emit("sync:started", { trigger }); logger.info(`Starting Linear sync (trigger: ${trigger})`); const result = await this.syncEngine.sync(); this.lastSyncTime = now; if (result.success) { logger.info( `Linear sync completed: ${result.synced.toLinear} to Linear, ${result.synced.fromLinear} from Linear, ${result.synced.updated} updated` ); this.emit("sync:completed", { trigger, result }); } else { logger.error(`Linear sync failed: ${result.errors.join(", ")}`); this.emit("sync:failed", { trigger, result }); } return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error(`Linear sync error: ${errorMessage}`); const result = { success: false, synced: { toLinear: 0, fromLinear: 0, updated: 0 }, conflicts: [], errors: [errorMessage] }; this.emit("sync:failed", { trigger, result, error }); return result; } finally { release(); } } /** * Sync on session start */ async syncOnStart() { if (this.config.syncOnSessionStart) { return await this.performSync("session-start"); } return null; } /** * Sync on session end */ async syncOnEnd() { if (this.config.syncOnSessionEnd) { return await this.performSync("session-end"); } return null; } /** * Update sync configuration */ updateConfig(newConfig) { this.config = { ...this.config, ...newConfig }; this.syncEngine.updateConfig(newConfig); if (newConfig.autoSyncInterval !== void 0 || newConfig.autoSync !== void 0) { this.setupPeriodicSync(); } } /** * Get sync status */ getStatus() { const nextSyncTime = this.config.autoSync && this.config.autoSyncInterval ? this.lastSyncTime + this.config.autoSyncInterval * 60 * 1e3 : null; return { enabled: this.config.enabled, syncInProgress: this.syncMutex.isLocked(), lastSyncTime: this.lastSyncTime, nextSyncTime, config: this.config }; } /** * Stop all sync activities */ stop() { if (this.syncTimer) { clearInterval(this.syncTimer); this.syncTimer = void 0; } if (this.pendingSyncTimer) { clearTimeout(this.pendingSyncTimer); this.pendingSyncTimer = void 0; } this.removeAllListeners(); logger.info("Linear sync manager stopped"); } /** * Force an immediate sync */ async forceSync() { return await this.performSync("manual"); } } const DEFAULT_SYNC_MANAGER_CONFIG = { enabled: true, direction: "bidirectional", autoSync: true, autoSyncInterval: 15, // minutes conflictResolution: "newest_wins", syncOnTaskChange: true, syncOnSessionStart: true, syncOnSessionEnd: true, debounceInterval: 5e3 // 5 seconds }; export { DEFAULT_SYNC_MANAGER_CONFIG, LinearSyncManager };