UNPKG

task-master-sync

Version:

A bidirectional synchronization tool between TaskMaster AI and Monday.com with automatic item recreation

856 lines (711 loc) 26.4 kB
/** * Sync State Manager Module * * Manages the local sync state, tracking Monday.com item IDs and timestamps. * Uses atomic write operations and file locking to prevent data corruption. */ const fs = require('fs-extra'); const path = require('path'); const os = require('os'); const { v4: uuidv4 } = require('uuid'); const { Logger } = require('../utils/logger'); // Constants const DEFAULT_SYNC_FILE = '.taskmaster_sync_state.json'; const DEFAULT_LOCK_TIMEOUT_MS = 5000; // 5 seconds // Track locks with a memory map const activeLocks = new Map(); /** * Creates a sync state manager instance * @param {Object} options - Configuration options * @returns {Object} - Sync state manager instance */ function createSyncStateManager(options = {}) { // Configure paths const syncFilePath = options.syncFilePath || DEFAULT_SYNC_FILE; const lockTimeoutMs = options.lockTimeoutMs || DEFAULT_LOCK_TIMEOUT_MS; // In-memory cache of the sync state let syncStateCache = null; let cacheTimestamp = null; /** * Generate a lock file path for a given file * @param {string} filePath - Path to the original file * @returns {string} - Path to the lock file */ function getLockFilePath(filePath) { return `${filePath}.lock`; } /** * Acquires a file lock with timeout * @param {string} filePath - Path to the file to lock * @param {number} timeoutMs - Timeout in milliseconds * @returns {Promise<string>} - Lock ID if successful */ async function acquireLock(filePath, timeoutMs = lockTimeoutMs) { const lockFilePath = getLockFilePath(filePath); const lockId = uuidv4(); const startTime = Date.now(); // Try to acquire the lock let attempts = 0; const maxAttempts = 1000; // Prevent infinite loops while (attempts < maxAttempts) { attempts++; try { // Check if we already have a lock in memory if (activeLocks.has(filePath)) { const existingLock = activeLocks.get(filePath); if (existingLock.id === lockId) { // We already have this lock return lockId; } } // Try to create the lock file await fs.writeFile(lockFilePath, lockId, { flag: 'wx' }); // Successfully created the lock file Logger.debug(`Acquired lock for ${filePath} with ID ${lockId}`); // Store the lock in memory activeLocks.set(filePath, { id: lockId, timestamp: Date.now() }); return lockId; } catch (error) { // Check if the error is because the lock file already exists if (error.code !== 'EEXIST') { throw error; } // Check if we've timed out if (Date.now() - startTime > timeoutMs) { throw new Error(`Failed to acquire lock for ${filePath} after ${timeoutMs}ms`); } // Check if the lock is stale try { const stats = await fs.stat(lockFilePath); const lockAge = Date.now() - stats.mtimeMs; if (lockAge > timeoutMs) { // Stale lock, remove it Logger.warn(`Removing stale lock for ${filePath} (age: ${lockAge}ms)`); await fs.unlink(lockFilePath); continue; } } catch (statError) { // If the file doesn't exist, someone else might have removed it if (statError.code === 'ENOENT') { continue; } throw statError; } // Wait a bit and try again await new Promise(resolve => setTimeout(resolve, 100)); } } } /** * Releases a file lock * @param {string} filePath - Path to the file * @param {string} lockId - Lock ID to release * @returns {Promise<boolean>} - True if the lock was released */ async function releaseLock(filePath, lockId) { const lockFilePath = getLockFilePath(filePath); // Check if we have the lock in memory if (activeLocks.has(filePath)) { const existingLock = activeLocks.get(filePath); // Only release if the lock ID matches if (existingLock.id !== lockId) { Logger.warn(`Attempted to release lock ${lockId} for ${filePath}, but we have lock ${existingLock.id}`); return false; } // Remove from memory activeLocks.delete(filePath); } try { // Check if the lock file exists and contains our lock ID let lockFileContent; try { lockFileContent = await fs.readFile(lockFilePath, 'utf8'); } catch (readError) { if (readError.code === 'ENOENT') { // Lock file already gone return true; } throw readError; } // Verify the lock ID if (lockFileContent !== lockId) { Logger.warn(`Lock file ${lockFilePath} contains ID ${lockFileContent}, expected ${lockId}`); return false; } // Remove the lock file await fs.unlink(lockFilePath); Logger.debug(`Released lock for ${filePath} with ID ${lockId}`); return true; } catch (error) { Logger.error(`Failed to release lock for ${filePath}: ${error.message}`); return false; } } /** * Reads the sync state from file * @param {boolean} bypassCache - Whether to bypass the cache * @returns {Promise<Object>} - The sync state */ async function readSyncState(bypassCache = false) { // Check cache first if not bypassing if (!bypassCache && syncStateCache && cacheTimestamp) { const cacheAge = Date.now() - cacheTimestamp; if (cacheAge < 5000) { // Cache valid for 5 seconds Logger.debug('Using cached sync state'); return syncStateCache; } } // Acquire a lock for reading const lockId = await acquireLock(syncFilePath); try { // Check if the file exists if (!fs.existsSync(syncFilePath)) { // Create a new sync state const newSyncState = getEmptySyncState(); // Write it to disk await writeSyncState(newSyncState); // Release the lock await releaseLock(syncFilePath, lockId); return newSyncState; } // Read the file const data = fs.readFileSync(syncFilePath, 'utf8'); // Parse the JSON const syncState = JSON.parse(data); // Release the lock await releaseLock(syncFilePath, lockId); // Ensure the sync state has the expected structure if (!syncState.taskMasterToMonday) syncState.taskMasterToMonday = {}; if (!syncState.mondayToTaskMaster) syncState.mondayToTaskMaster = {}; if (!syncState.taskMappings) syncState.taskMappings = []; // Update cache syncStateCache = syncState; cacheTimestamp = Date.now(); return syncState; } catch (error) { // Release the lock if it was acquired try { await releaseLock(syncFilePath, lockId); // eslint-disable-next-line no-unused-vars } catch (unlockError) { // Ignore unlock errors } Logger.error(`Error reading sync state: ${error.message}`); // Return an empty sync state return getEmptySyncState(); } } /** * Writes the sync state to file * @param {Object} syncState - The sync state to write * @returns {Promise<void>} */ async function writeSyncState(syncState) { if (!syncState || typeof syncState !== 'object') { throw new Error('Invalid sync state'); } // Ensure the structure is valid if (!syncState.items || typeof syncState.items !== 'object') { syncState.items = {}; } // Always update the lastSync timestamp syncState.lastSync = Date.now(); // Acquire a lock for writing const lockId = await acquireLock(syncFilePath); try { // Create a temporary file to write to (for atomic writes) const tempFilePath = path.join(os.tmpdir(), `taskmaster-sync-${uuidv4()}.json`); try { // Write to the temporary file await fs.writeFile(tempFilePath, JSON.stringify(syncState, null, 2), 'utf8'); // Move the temporary file to the actual file (atomic operation) await fs.move(tempFilePath, syncFilePath, { overwrite: true }); // Update cache syncStateCache = syncState; cacheTimestamp = Date.now(); Logger.debug('Sync state written successfully'); } catch (error) { // Clean up temporary file if it exists try { await fs.unlink(tempFilePath); // eslint-disable-next-line no-unused-vars } catch (unlinkError) { // Ignore errors from unlink } throw error; } } finally { // Release the lock await releaseLock(syncFilePath, lockId); } } /** * Creates an empty sync state * @returns {Object} - An empty sync state */ function getEmptySyncState() { return { version: '1.0', lastSync: null, taskMappings: [], mondayToTaskMaster: {}, taskMasterToMonday: {}, items: {} // For backward compatibility }; } /** * Gets the last synced timestamp for a Monday.com item * @param {string} mondayItemId - The Monday.com item ID * @returns {Promise<number|null>} - The timestamp or null if not found */ async function getLastSyncedTimestamp(mondayItemId) { const syncState = await readSyncState(); // For backward compatibility with old tests if (syncState.items && syncState.items[mondayItemId]) { return syncState.items[mondayItemId].timestamp; } // New implementation with taskMappings const taskMapping = syncState.taskMappings.find( mapping => mapping.mondayItemId === String(mondayItemId) ); if (taskMapping && taskMapping.timestamp) { return taskMapping.timestamp; } return null; } /** * Updates the synced timestamp for a Monday.com item * @param {string} mondayItemId - The Monday.com item ID * @param {string} taskmasterTaskId - The TaskMaster task ID * @param {number} timestamp - The sync timestamp * @returns {Promise<void>} */ async function updateSyncedTimestamp(mondayItemId, taskmasterTaskId, timestamp = Date.now()) { const syncState = await readSyncState(); // For backward compatibility with old tests if (!syncState.items) { syncState.items = {}; } syncState.items[mondayItemId] = { taskmasterTaskId, timestamp }; // New implementation with taskMappings const existingMapping = syncState.taskMappings.find( mapping => mapping.mondayItemId === String(mondayItemId) ); if (existingMapping) { existingMapping.taskmasterTaskId = String(taskmasterTaskId); existingMapping.timestamp = timestamp; } else { syncState.taskMappings.push({ mondayItemId: String(mondayItemId), taskmasterTaskId: String(taskmasterTaskId), timestamp }); } // Update bidirectional mappings syncState.mondayToTaskMaster[mondayItemId] = taskmasterTaskId; if (!syncState.taskMasterToMonday[taskmasterTaskId]) { syncState.taskMasterToMonday[taskmasterTaskId] = []; } if (!syncState.taskMasterToMonday[taskmasterTaskId].includes(mondayItemId)) { syncState.taskMasterToMonday[taskmasterTaskId].push(mondayItemId); } await writeSyncState(syncState); } /** * Removes a synced item from the sync state * @param {string} mondayItemId - The Monday.com item ID * @returns {Promise<boolean>} - Whether the item was removed */ async function removeSyncedItem(mondayItemId) { const syncState = await readSyncState(); // For backward compatibility with old tests let itemExisted = false; if (syncState.items && syncState.items[mondayItemId]) { const taskmasterTaskId = syncState.items[mondayItemId].taskmasterTaskId; delete syncState.items[mondayItemId]; itemExisted = true; // Also clean up the bidirectional mappings if (syncState.mondayToTaskMaster && syncState.mondayToTaskMaster[mondayItemId]) { delete syncState.mondayToTaskMaster[mondayItemId]; } if (taskmasterTaskId && syncState.taskMasterToMonday && syncState.taskMasterToMonday[taskmasterTaskId]) { const index = syncState.taskMasterToMonday[taskmasterTaskId].indexOf(mondayItemId); if (index !== -1) { syncState.taskMasterToMonday[taskmasterTaskId].splice(index, 1); // Clean up empty arrays if (syncState.taskMasterToMonday[taskmasterTaskId].length === 0) { delete syncState.taskMasterToMonday[taskmasterTaskId]; } } } } // New implementation with taskMappings const mappingIndex = syncState.taskMappings.findIndex( mapping => mapping.mondayItemId === String(mondayItemId) ); if (mappingIndex !== -1) { syncState.taskMappings.splice(mappingIndex, 1); itemExisted = true; } if (itemExisted) { await writeSyncState(syncState); return true; } return false; } /** * Gets all synced Monday.com item IDs * @returns {Promise<string[]>} - Array of Monday.com item IDs */ async function getAllSyncedItemIds() { const syncState = await readSyncState(); // For backward compatibility with old tests if (syncState.items) { const oldIds = Object.keys(syncState.items); if (oldIds.length > 0) { return oldIds; } } // New implementation with taskMappings return syncState.taskMappings.map(mapping => mapping.mondayItemId); } /** * Gets all synced items * @returns {Promise<Object>} - Object mapping Monday.com item IDs to TaskMaster task IDs */ async function getAllSyncedItems() { const syncState = await readSyncState(); // For backward compatibility with old tests if (syncState.items) { const oldItems = {}; Object.entries(syncState.items).forEach(([mondayItemId, data]) => { oldItems[mondayItemId] = data.taskmasterTaskId; }); if (Object.keys(oldItems).length > 0) { return oldItems; } } // New implementation with taskMappings const items = {}; syncState.taskMappings.forEach(mapping => { items[mapping.mondayItemId] = mapping.taskmasterTaskId; }); return items; } /** * Gets the TaskMaster task ID for a Monday.com item * @param {string} mondayItemId - The Monday.com item ID * @returns {Promise<string|null>} - The TaskMaster task ID or null if not found */ async function getTaskmasterTaskId(mondayItemId) { const syncState = await readSyncState(); // For backward compatibility with old tests if (syncState.items && syncState.items[mondayItemId]) { return syncState.items[mondayItemId].taskmasterTaskId; } // New implementation with mondayToTaskMaster if (syncState.mondayToTaskMaster && syncState.mondayToTaskMaster[mondayItemId]) { return syncState.mondayToTaskMaster[mondayItemId]; } return null; } /** * Gets the Monday.com item IDs for a TaskMaster task * @param {string} taskmasterTaskId - The TaskMaster task ID * @returns {Promise<string[]>} - Array of Monday.com item IDs */ async function getMondayItemIdsForTask(taskmasterTaskId) { const syncState = await readSyncState(); // For backward compatibility with old tests if (syncState.items) { const oldItems = []; Object.entries(syncState.items).forEach(([mondayItemId, data]) => { if (data.taskmasterTaskId === taskmasterTaskId) { oldItems.push(mondayItemId); } }); if (oldItems.length > 0) { return oldItems; } } // New implementation with taskMasterToMonday if (syncState.taskMasterToMonday && syncState.taskMasterToMonday[taskmasterTaskId]) { return syncState.taskMasterToMonday[taskmasterTaskId]; } return []; } /** * Cleans up old entries from the sync state * @param {number} maxAgeMs - Max age in milliseconds (default: 30 days) * @returns {Promise<number>} - Number of entries removed */ async function cleanupOldEntries(maxAgeMs = 30 * 24 * 60 * 60 * 1000) { // Default 30 days const syncState = await readSyncState(); const now = Date.now(); let count = 0; // For backward compatibility with old tests - clean up items if (syncState.items) { const itemsToRemove = []; // Find old items Object.entries(syncState.items).forEach(([mondayItemId, data]) => { if (now - data.timestamp > maxAgeMs) { itemsToRemove.push(mondayItemId); } }); // Remove old items itemsToRemove.forEach(mondayItemId => { delete syncState.items[mondayItemId]; count++; }); } // Clean up taskMappings const mappingsToRemove = []; syncState.taskMappings.forEach((mapping, index) => { if (mapping.timestamp && now - mapping.timestamp > maxAgeMs) { mappingsToRemove.push(index); } }); // Remove in reverse order to avoid index shifting mappingsToRemove.reverse().forEach(index => { const mapping = syncState.taskMappings[index]; // Also clean up bidirectional mappings if (mapping.mondayItemId && syncState.mondayToTaskMaster[mapping.mondayItemId]) { delete syncState.mondayToTaskMaster[mapping.mondayItemId]; } if (mapping.taskmasterTaskId && syncState.taskMasterToMonday[mapping.taskmasterTaskId]) { const mondayIds = syncState.taskMasterToMonday[mapping.taskmasterTaskId]; const mondayIndex = mondayIds.indexOf(mapping.mondayItemId); if (mondayIndex !== -1) { mondayIds.splice(mondayIndex, 1); // Clean up empty arrays if (mondayIds.length === 0) { delete syncState.taskMasterToMonday[mapping.taskmasterTaskId]; } } } syncState.taskMappings.splice(index, 1); count++; }); if (count > 0) { await writeSyncState(syncState); } return count; } /** * Clears the in-memory cache */ function clearCache() { syncStateCache = null; cacheTimestamp = null; } /** * Gets the time of the last sync * @returns {Promise<number|null>} - Timestamp of the last sync or null */ async function getLastSyncTime() { const syncState = await readSyncState(); return syncState.lastSync; } /** * Gets the update ID for a specific task and Monday.com item * @param {string} taskId - The TaskMaster task ID * @param {string} mondayItemId - The Monday.com item ID * @returns {Promise<string|null>} - The update ID or null if not found */ async function getUpdateIdForTask(taskId, mondayItemId) { const syncState = await readSyncState(); // If no task mappings exist, return null if (!syncState.taskMappings || !Array.isArray(syncState.taskMappings)) { return null; } // Find the task mapping const taskMapping = syncState.taskMappings.find( mapping => mapping.taskId === String(taskId) && mapping.mondayItemId === String(mondayItemId) ); if (taskMapping && taskMapping.mondayUpdateId) { return taskMapping.mondayUpdateId; } return null; } /** * Stores the update ID for a specific task and Monday.com item * @param {string} taskId - The TaskMaster task ID * @param {string} mondayItemId - The Monday.com item ID * @param {string} updateId - The Monday.com update ID * @returns {Promise<void>} */ async function storeUpdateIdForTask(taskId, mondayItemId, updateId) { const syncState = await readSyncState(); // Ensure taskMappings exists if (!syncState.taskMappings) { syncState.taskMappings = []; } // Find the task mapping const taskMapping = syncState.taskMappings.find( mapping => mapping.taskId === String(taskId) && mapping.mondayItemId === String(mondayItemId) ); if (taskMapping) { // Update existing mapping taskMapping.mondayUpdateId = String(updateId); taskMapping.lastSynced = new Date().toISOString(); } else { // Create new mapping syncState.taskMappings.push({ taskId: String(taskId), mondayItemId: String(mondayItemId), mondayUpdateId: String(updateId), lastSynced: new Date().toISOString() }); } await writeSyncState(syncState); } /** * Removes the update ID for a specific task and Monday.com item * @param {string} taskId - The TaskMaster task ID * @param {string} mondayItemId - The Monday.com item ID * @returns {Promise<boolean>} - True if the update ID was removed */ async function removeUpdateIdForTask(taskId, mondayItemId) { const syncState = await readSyncState(); // If no task mappings exist, return false if (!syncState.taskMappings || !Array.isArray(syncState.taskMappings)) { return false; } // Find the task mapping const taskMappingIndex = syncState.taskMappings.findIndex( mapping => mapping.taskId === String(taskId) && mapping.mondayItemId === String(mondayItemId) ); if (taskMappingIndex !== -1) { // Update existing mapping if (syncState.taskMappings[taskMappingIndex].mondayUpdateId) { delete syncState.taskMappings[taskMappingIndex].mondayUpdateId; syncState.taskMappings[taskMappingIndex].lastSynced = new Date().toISOString(); await writeSyncState(syncState); return true; } } return false; } /** * Removes a Monday.com item from the sync state * @param {string} mondayItemId - The Monday.com item ID to remove * @returns {Promise<boolean>} - Whether the item was removed */ async function removeMondayItem(mondayItemId) { if (!mondayItemId) { throw new Error('Monday item ID is required'); } Logger.debug(`Removing Monday.com item ${mondayItemId} from sync state`); // Get the current sync state const syncState = await readSyncState(); // Check if the item exists in the sync state if (!syncState.items || !syncState.items[mondayItemId]) { Logger.warn(`Monday.com item ${mondayItemId} not found in sync state`); return false; } // Store the task ID before removing the item const taskId = syncState.items[mondayItemId].taskmasterTaskId; // Remove from items section delete syncState.items[mondayItemId]; // Remove from mondayToTaskMaster section if (syncState.mondayToTaskMaster && syncState.mondayToTaskMaster[mondayItemId]) { delete syncState.mondayToTaskMaster[mondayItemId]; } // Remove from taskMasterToMonday section if (syncState.taskMasterToMonday && taskId && syncState.taskMasterToMonday[taskId]) { const index = syncState.taskMasterToMonday[taskId].indexOf(mondayItemId); if (index !== -1) { syncState.taskMasterToMonday[taskId].splice(index, 1); // If there are no more Monday items for this task, remove the entry if (syncState.taskMasterToMonday[taskId].length === 0) { delete syncState.taskMasterToMonday[taskId]; } } } // Remove from taskMappings section if (syncState.taskMappings) { const index = syncState.taskMappings.findIndex(mapping => mapping.mondayItemId === mondayItemId); if (index !== -1) { syncState.taskMappings.splice(index, 1); } } // Write updated sync state await writeSyncState(syncState); Logger.info(`Removed Monday.com item ${mondayItemId} (linked to task ${taskId}) from sync state`); return true; } /** * Removes a local task from the sync state * @param {string} taskId - The task ID to remove * @returns {Promise<string|null>} - The Monday.com item ID that was removed, or null if not found */ async function removeLocalTask(taskId) { if (!taskId) { throw new Error('Task ID is required'); } Logger.debug(`Removing local task ${taskId} from sync state`); // Get the current sync state const syncState = await readSyncState(); if (!syncState.items) { Logger.warn(`No items found in sync state`); return null; } // Find the Monday item ID for this task let mondayItemId = null; // Loop through all items to find the one matching the task ID for (const [itemId, item] of Object.entries(syncState.items)) { if (item.taskId === taskId) { mondayItemId = itemId; break; } } // If no Monday item was found, return null if (!mondayItemId) { Logger.warn(`No Monday.com item found for task ${taskId}`); return null; } // Remove the item from the sync state delete syncState.items[mondayItemId]; // Save the updated sync state await writeSyncState(syncState); Logger.info(`Removed task ${taskId} (linked to Monday.com item ${mondayItemId}) from sync state`); return mondayItemId; } // Return the public API return { readSyncState, writeSyncState, getLastSyncedTimestamp, updateSyncedTimestamp, removeSyncedItem, getAllSyncedItemIds, getAllSyncedItems, getTaskmasterTaskId, getMondayItemIdsForTask, cleanupOldEntries, getLastSyncTime, clearCache, getUpdateIdForTask, storeUpdateIdForTask, removeUpdateIdForTask, removeMondayItem, getEmptySyncState, removeLocalTask }; } // Create a default instance const defaultInstance = createSyncStateManager(); // Export the default instance and the factory function module.exports = { ...defaultInstance, createSyncStateManager };