UNPKG

claude-flow-novice

Version:

Claude Flow Novice - Advanced orchestration platform for multi-agent AI workflows with CFN Loop architecture Includes Local RuVector Accelerator and all CFN skills for complete functionality.

367 lines (366 loc) 12.8 kB
/** * File Operations Utilities * * Provides atomic file writes and file locking mechanisms for safe concurrent operations. * Part of Task 0.5: Implementation Tooling & Utilities (Foundation) * * Usage: * await atomicWrite('/path/to/file.json', JSON.stringify(data)); * const lock = await acquireLock('/path/to/file.json'); * // ... perform operations ... * await releaseLock(lock); */ import * as fs from 'fs'; import * as path from 'path'; import { promisify } from 'util'; import { randomUUID } from 'crypto'; import { createError, ErrorCode, createTimeoutError } from './errors.js'; import { createLogger } from './logging.js'; const logger = createLogger('file-operations'); const fsWriteFile = promisify(fs.writeFile); const fsReadFile = promisify(fs.readFile); const fsRename = promisify(fs.rename); const fsUnlink = promisify(fs.unlink); const fsStat = promisify(fs.stat); const fsMkdir = promisify(fs.mkdir); const fsAccess = promisify(fs.access); /** * Default lock options */ const DEFAULT_LOCK_OPTIONS = { timeoutMs: 30000, retryIntervalMs: 100, staleTimeoutMs: 60000 }; /** * Atomically write content to a file * * Pattern: Write to temp file → verify → move to target * This ensures that the file is never left in a partially written state. * * @param filePath - Target file path * @param content - Content to write * @param encoding - File encoding (default: 'utf8') * @returns Promise that resolves when write is complete * @throws FILE_WRITE_FAILED if write operation fails */ export async function atomicWrite(filePath, content, encoding = 'utf8') { const absolutePath = path.resolve(filePath); const dir = path.dirname(absolutePath); const tempPath = path.join(dir, `.${path.basename(absolutePath)}.${randomUUID()}.tmp`); try { // Ensure directory exists await ensureDirectory(dir); // Write to temporary file logger.debug('Writing to temporary file', { tempPath }); await fsWriteFile(tempPath, content, encoding); // Verify write by reading back const written = await fsReadFile(tempPath, encoding); if (written !== content) { throw createError(ErrorCode.FILE_WRITE_FAILED, 'Content verification failed after write', { filePath: absolutePath, tempPath }); } // Atomically move temp file to target logger.debug('Moving temp file to target', { tempPath, target: absolutePath }); await fsRename(tempPath, absolutePath); logger.info('Atomic write completed', { filePath: absolutePath }); } catch (error) { // Clean up temp file if it exists try { await fsUnlink(tempPath); } catch { // Ignore cleanup errors } throw createError(ErrorCode.FILE_WRITE_FAILED, `Failed to write file: ${absolutePath}`, { filePath: absolutePath, tempPath }, error instanceof Error ? error : undefined); } } /** * Acquire a lock on a file * * Creates a lock file with metadata about the lock holder. * Implements stale lock detection and automatic cleanup. * * @param filePath - File path to lock * @param options - Lock options * @returns FileLock object * @throws LOCK_TIMEOUT if lock cannot be acquired within timeout */ export async function acquireLock(filePath, options = {}) { const opts = { ...DEFAULT_LOCK_OPTIONS, ...options }; const absolutePath = path.resolve(filePath); const lockPath = `${absolutePath}.lock`; const lockId = randomUUID(); const startTime = Date.now(); logger.debug('Acquiring lock', { filePath: absolutePath, lockId }); while(Date.now() - startTime < opts.timeoutMs){ try { // Check if lock file exists const lockExists = await fileExists(lockPath); if (lockExists) { // Check if lock is stale const isStale = await isLockStale(lockPath, opts.staleTimeoutMs); if (isStale) { logger.warn('Removing stale lock', { lockPath }); await fsUnlink(lockPath); } else { // Lock is held by another process, wait and retry await sleep(opts.retryIntervalMs); continue; } } // Attempt to create lock file const lockData = { filePath: absolutePath, lockPath, acquired: new Date(), lockId, pid: process.pid }; // Write lock file atomically await atomicWrite(lockPath, JSON.stringify(lockData, null, 2)); // Verify we actually acquired the lock (check for race condition) const verifyData = await fsReadFile(lockPath, 'utf8'); const verifyLock = JSON.parse(verifyData); if (verifyLock.lockId !== lockId) { // Another process won the race logger.debug('Lost lock race', { lockId, winnerId: verifyLock.lockId }); await sleep(opts.retryIntervalMs); continue; } logger.info('Lock acquired', { filePath: absolutePath, lockId }); return lockData; } catch (error) { logger.error('Error acquiring lock', error instanceof Error ? error : undefined, { filePath: absolutePath, lockId }); throw createError(ErrorCode.LOCK_TIMEOUT, `Failed to acquire lock: ${absolutePath}`, { filePath: absolutePath, lockPath }, error instanceof Error ? error : undefined); } } // Timeout reached throw createTimeoutError(`acquire lock on ${absolutePath}`, opts.timeoutMs); } /** * Release a file lock * * @param lock - FileLock object from acquireLock * @returns Promise that resolves when lock is released */ export async function releaseLock(lock) { try { // Verify lock still exists and belongs to us const lockExists = await fileExists(lock.lockPath); if (!lockExists) { logger.warn('Lock file already removed', { lockPath: lock.lockPath }); return; } const currentData = await fsReadFile(lock.lockPath, 'utf8'); const currentLock = JSON.parse(currentData); if (currentLock.lockId !== lock.lockId) { logger.warn('Lock was taken by another process', { ourLockId: lock.lockId, currentLockId: currentLock.lockId }); return; } // Remove lock file await fsUnlink(lock.lockPath); logger.info('Lock released', { filePath: lock.filePath, lockId: lock.lockId }); } catch (error) { logger.error('Error releasing lock', error instanceof Error ? error : undefined, { lockPath: lock.lockPath, lockId: lock.lockId }); throw createError(ErrorCode.FILE_WRITE_FAILED, `Failed to release lock: ${lock.lockPath}`, { lockPath: lock.lockPath, lockId: lock.lockId }, error instanceof Error ? error : undefined); } } /** * Execute a function with a file lock * * @param filePath - File path to lock * @param fn - Function to execute while holding the lock * @param options - Lock options * @returns Result of the function */ export async function withLock(filePath, fn, options = {}) { const lock = await acquireLock(filePath, options); try { return await fn(); } finally{ await releaseLock(lock); } } /** * Check if a lock is stale * * @param lockPath - Path to lock file * @param staleTimeoutMs - Stale timeout in milliseconds * @returns True if lock is stale */ async function isLockStale(lockPath, staleTimeoutMs) { try { const lockData = await fsReadFile(lockPath, 'utf8'); const lock = JSON.parse(lockData); const acquiredTime = new Date(lock.acquired).getTime(); const now = Date.now(); const age = now - acquiredTime; return age > staleTimeoutMs; } catch { // If we can't read the lock file, consider it stale return true; } } /** * Check if a file exists * * @param filePath - File path to check * @returns True if file exists */ export async function fileExists(filePath) { try { await fsAccess(filePath, fs.constants.F_OK); return true; } catch { return false; } } /** * Ensure directory exists, creating it if necessary * * @param dirPath - Directory path * @returns Promise that resolves when directory exists */ export async function ensureDirectory(dirPath) { try { await fsMkdir(dirPath, { recursive: true }); } catch (error) { // Ignore error if directory already exists if (error.code !== 'EEXIST') { throw error; } } } /** * Sleep for specified duration * * @param ms - Duration in milliseconds * @returns Promise that resolves after duration */ function sleep(ms) { return new Promise((resolve)=>setTimeout(resolve, ms)); } /** * Read file with automatic retry * * @param filePath - File path to read * @param encoding - File encoding (default: 'utf8') * @param maxRetries - Maximum retry attempts (default: 3) * @returns File content */ export async function readFileWithRetry(filePath, encoding = 'utf8', maxRetries = 3) { let lastError; for(let attempt = 0; attempt < maxRetries; attempt++){ try { return await fsReadFile(filePath, encoding); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); if (attempt < maxRetries - 1) { await sleep(100 * Math.pow(2, attempt)); // Exponential backoff } } } throw createError(ErrorCode.FILE_NOT_FOUND, `Failed to read file after ${maxRetries} attempts: ${filePath}`, { filePath, maxRetries }, lastError); } /** * Write file with automatic retry * * @param filePath - File path to write * @param content - Content to write * @param encoding - File encoding (default: 'utf8') * @param maxRetries - Maximum retry attempts (default: 3) * @returns Promise that resolves when write is complete */ export async function writeFileWithRetry(filePath, content, encoding = 'utf8', maxRetries = 3) { let lastError; for(let attempt = 0; attempt < maxRetries; attempt++){ try { await atomicWrite(filePath, content, encoding); return; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); if (attempt < maxRetries - 1) { await sleep(100 * Math.pow(2, attempt)); // Exponential backoff } } } throw createError(ErrorCode.FILE_WRITE_FAILED, `Failed to write file after ${maxRetries} attempts: ${filePath}`, { filePath, maxRetries }, lastError); } /** * Copy file atomically * * @param sourcePath - Source file path * @param targetPath - Target file path * @returns Promise that resolves when copy is complete */ export async function atomicCopy(sourcePath, targetPath) { const content = await fsReadFile(sourcePath, 'utf8'); await atomicWrite(targetPath, content); logger.info('Atomic copy completed', { source: sourcePath, target: targetPath }); } /** * Move file atomically * * @param sourcePath - Source file path * @param targetPath - Target file path * @returns Promise that resolves when move is complete */ export async function atomicMove(sourcePath, targetPath) { const absoluteSource = path.resolve(sourcePath); const absoluteTarget = path.resolve(targetPath); try { await fsRename(absoluteSource, absoluteTarget); logger.info('Atomic move completed', { source: absoluteSource, target: absoluteTarget }); } catch (error) { // If rename fails (e.g., cross-device), fall back to copy + delete await atomicCopy(absoluteSource, absoluteTarget); await fsUnlink(absoluteSource); } } //# sourceMappingURL=file-operations.js.map