UNPKG

gitvan

Version:

Autonomic Git-native development automation platform with AI-powered workflows

492 lines (422 loc) 13 kB
/** * GitVan v2 - useLock() Composable * Provides distributed locking for job coordination and concurrency control */ import { useGitVan, tryUseGitVan, withGitVan } from "../core/context.mjs"; import { useGit } from "./git/index.mjs"; import { acquireLock, releaseLock, generateLockRef, } from "../runtime/locks.mjs"; import { createHash } from "node:crypto"; import { join } from "node:path"; export function useLock() { // Get context from unctx - this must be called synchronously let ctx; try { ctx = useGitVan(); } catch { ctx = tryUseGitVan?.() || null; } // Resolve working directory and environment const cwd = (ctx && ctx.cwd) || process.cwd(); const env = { ...process.env, ...(ctx && ctx.env ? ctx.env : {}), TZ: "UTC", // Always override to UTC for determinism LANG: "C", // Always override to C locale for determinism }; const base = { cwd, env }; // Initialize dependencies const git = useGit(); return { // Context properties (exposed for testing) cwd: base.cwd, env: base.env, // === Lock Management === async acquire(lockName, options = {}) { const { timeout = 30000, retryInterval = 1000, maxRetries = 30, metadata = {}, } = options; try { const gitInfo = await git.info(); const lockRef = this.getLockRef(lockName, gitInfo); // Create lock data const lockData = { id: this.generateLockId(), name: lockName, worktree: gitInfo.worktree, branch: gitInfo.branch, commit: gitInfo.head, timestamp: new Date().toISOString(), timeout: timeout, metadata, }; // Try to acquire lock const acquired = await acquireLock(lockRef, JSON.stringify(lockData)); if (acquired) { return { id: lockData.id, name: lockName, ref: lockRef, acquired: true, timestamp: lockData.timestamp, timeout: timeout, }; } else { return { id: null, name: lockName, ref: lockRef, acquired: false, error: "Lock acquisition timeout", }; } } catch (error) { throw new Error(`Failed to acquire lock ${lockName}: ${error.message}`); } }, async release(lockName) { try { const gitInfo = await git.info(); const lockRef = this.getLockRef(lockName, gitInfo); const released = await releaseLock(lockRef); return { name: lockName, ref: lockRef, released, }; } catch (error) { throw new Error(`Failed to release lock ${lockName}: ${error.message}`); } }, async isLocked(lockName) { try { const gitInfo = await git.info(); const lockRef = this.getLockRef(lockName, gitInfo); // Check if lock ref exists const lockData = await git.getRef(lockRef); return !!lockData; } catch (error) { return false; } }, async getLockInfo(lockName) { try { const gitInfo = await git.info(); const lockRef = this.getLockRef(lockName, gitInfo); const locked = await isLocked(lockRef); if (!locked) { return null; } // Get lock data from Git ref const lockData = await git.getRef(lockRef); if (!lockData) { return null; } return { name: lockName, ref: lockRef, locked: true, data: lockData, }; } catch (error) { return null; } }, // === Lock Status === async status(lockName) { try { const gitInfo = await git.info(); const lockRef = this.getLockRef(lockName, gitInfo); const locked = await isLocked(lockRef); const lockInfo = locked ? await this.getLockInfo(lockName) : null; return { name: lockName, locked, ref: lockRef, info: lockInfo, worktree: gitInfo.worktree, branch: gitInfo.branch, }; } catch (error) { return { name: lockName, locked: false, error: error.message, }; } }, async list(options = {}) { const { worktree = null, branch = null, includeExpired = false, } = options; try { const gitInfo = await git.info(); const locksRoot = "refs/gitvan/locks"; // Get all lock refs const lockRefs = await git.listRefs(locksRoot); const locks = []; for (const ref of lockRefs) { try { const lockData = await git.getRef(ref); if (!lockData) continue; const lock = { name: lockData.name, ref: ref, locked: true, worktree: lockData.worktree, branch: lockData.branch, commit: lockData.commit, timestamp: lockData.timestamp, timeout: lockData.timeout, metadata: lockData.metadata, }; // Apply filters if (worktree && lock.worktree !== worktree) continue; if (branch && lock.branch !== branch) continue; // Check if expired if (!includeExpired) { const now = new Date(); const lockTime = new Date(lock.timestamp); const expiryTime = new Date(lockTime.getTime() + lock.timeout); if (now > expiryTime) { continue; // Skip expired locks } } locks.push(lock); } catch (error) { console.warn(`Failed to process lock ref ${ref}:`, error.message); } } return locks; } catch (error) { throw new Error(`Failed to list locks: ${error.message}`); } }, // === Lock Cleanup === async cleanup(options = {}) { const { expired = true, orphaned = true, dryRun = false } = options; try { const locks = await this.list({ includeExpired: true }); const toCleanup = []; for (const lock of locks) { let shouldCleanup = false; // Check if expired if (expired) { const now = new Date(); const lockTime = new Date(lock.timestamp); const expiryTime = new Date(lockTime.getTime() + lock.timeout); if (now > expiryTime) { shouldCleanup = true; } } // Check if orphaned (worktree no longer exists) if (orphaned) { try { const worktreeExists = await git.worktreeExists(lock.worktree); if (!worktreeExists) { shouldCleanup = true; } } catch { shouldCleanup = true; } } if (shouldCleanup) { toCleanup.push(lock); } } if (dryRun) { return { total: locks.length, toCleanup: toCleanup.length, locks: toCleanup, }; } // Clean up locks let cleaned = 0; for (const lock of toCleanup) { try { await this.release(lock.name); cleaned++; } catch (error) { console.warn(`Failed to cleanup lock ${lock.name}:`, error.message); } } return { total: locks.length, cleaned, remaining: locks.length - cleaned, }; } catch (error) { throw new Error(`Failed to cleanup locks: ${error.message}`); } }, // === Lock Utilities === generateLockId() { return createHash("sha256") .update(`${Date.now()}-${Math.random()}`) .digest("hex") .slice(0, 16); }, getLockRef(lockName, gitInfo) { const worktreeId = this.getWorktreeId(gitInfo.worktree); const lockHash = createHash("sha256") .update(lockName) .digest("hex") .slice(0, 8); return `refs/gitvan/locks/${lockName}-${worktreeId}-${lockHash}`; }, getWorktreeId(worktreePath) { return createHash("sha256") .update(worktreePath) .digest("hex") .slice(0, 8); }, // === Lock Context Helpers === async createContext(lockName, options = {}) { const { additionalContext = {} } = options; try { const gitInfo = await git.info(); const lockRef = this.getLockRef(lockName, gitInfo); return { lock: { name: lockName, ref: lockRef, worktree: gitInfo.worktree, branch: gitInfo.branch, }, git: gitInfo, timestamp: new Date().toISOString(), ...additionalContext, }; } catch (error) { throw new Error( `Failed to create lock context for ${lockName}: ${error.message}` ); } }, // === Lock Fingerprinting === async getFingerprint(lockName) { try { const gitInfo = await git.info(); const lockRef = this.getLockRef(lockName, gitInfo); return createHash("sha256").update(lockRef).digest("hex").slice(0, 16); } catch (error) { throw new Error( `Failed to get lock fingerprint for ${lockName}: ${error.message}` ); } }, // === Lock Search === async search(query, options = {}) { const { fields = ["name", "worktree", "branch"] } = options; try { const locks = await this.list(); const results = []; for (const lock of locks) { let matches = false; for (const field of fields) { if ( lock[field] && lock[field].toLowerCase().includes(query.toLowerCase()) ) { matches = true; break; } } if (matches) { results.push(lock); } } return results; } catch (error) { throw new Error(`Failed to search locks: ${error.message}`); } }, // === Lock Analytics === async getStats(options = {}) { const { worktree = null, branch = null, since = null, until = null, } = options; try { const locks = await this.list({ worktree, branch }); const stats = { total: locks.length, active: locks.filter((l) => l.locked).length, expired: locks.filter((l) => { const now = new Date(); const lockTime = new Date(l.timestamp); const expiryTime = new Date(lockTime.getTime() + l.timeout); return now > expiryTime; }).length, byWorktree: {}, byBranch: {}, timeline: [], }; // Group by worktree locks.forEach((lock) => { stats.byWorktree[lock.worktree] = (stats.byWorktree[lock.worktree] || 0) + 1; }); // Group by branch locks.forEach((lock) => { stats.byBranch[lock.branch] = (stats.byBranch[lock.branch] || 0) + 1; }); // Create timeline const timeline = {}; locks.forEach((lock) => { const date = lock.timestamp.split("T")[0]; if (!timeline[date]) { timeline[date] = { acquired: 0, released: 0 }; } timeline[date].acquired = (timeline[date].acquired || 0) + 1; }); stats.timeline = Object.entries(timeline) .map(([date, counts]) => ({ date, ...counts })) .sort((a, b) => new Date(a.date) - new Date(b.date)); return stats; } catch (error) { throw new Error(`Failed to get lock stats: ${error.message}`); } }, // === Lock Export === async export(options = {}) { const { format = "json", worktree = null, branch = null } = options; try { const locks = await this.list({ worktree, branch }); if (format === "json") { return JSON.stringify(locks, null, 2); } else if (format === "csv") { // Convert to CSV format const headers = [ "name", "worktree", "branch", "commit", "timestamp", "timeout", ]; const csv = [ headers.join(","), ...locks.map((lock) => headers.map((h) => lock[h] || "").join(",")), ].join("\n"); return csv; } else { throw new Error(`Unsupported export format: ${format}`); } } catch (error) { throw new Error(`Failed to export locks: ${error.message}`); } }, }; }