Skip to main content
Glama
ExecutionLogService.ts15.8 kB
/** * ExecutionLogService - Tracks hook executions to prevent duplicate actions * * DESIGN PATTERNS: * - Repository pattern: Abstracts data access to execution log * - Query pattern: Provides efficient lookups for hook execution history * - Singleton cache: In-memory cache for performance * * CODING STANDARDS: * - Use instance methods for session-scoped operations * - Handle file system errors gracefully * - Optimize for performance with efficient data structures * * AVOID: * - Loading entire log file into memory * - Blocking I/O operations * - Complex parsing logic (keep it simple) */ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import * as os from 'node:os'; import * as crypto from 'node:crypto'; import type { LogEntry } from '../types'; /** * Type guard for Node.js filesystem errors */ function isNodeError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && 'code' in error; } /** * Log statistics returned by getStats method */ export interface LogStats { totalEntries: number; uniqueFiles: number; } /** * Input parameters for logging a hook execution */ export interface LogExecutionParams { sessionId: string; filePath: string; operation: string; decision: string; filePattern?: string; /** File modification timestamp (mtime) at time of execution */ fileMtime?: number; /** MD5 checksum of file content at time of execution */ fileChecksum?: string; /** List of files generated by scaffold method execution */ generatedFiles?: readonly string[] | string[]; /** Unique scaffold execution ID (for tracking specific scaffold operations) */ scaffoldId?: string; /** Project path where scaffold was executed */ projectPath?: string; /** Name of the scaffold feature/method that was used */ featureName?: string; } /** * Input parameters for checking if a hook execution has occurred */ export interface HasExecutedParams { /** File path to check */ filePath: string; /** Decision to check for (e.g., 'deny' means we already showed patterns) */ decision: string; /** Optional file pattern to match */ filePattern?: string; /** Optional project path to distinguish same patterns in different projects */ projectPath?: string; } /** * Service for tracking hook executions using an append-only log * Prevents duplicate hook actions (e.g., showing design patterns twice for same file) * Each session has its own log file for isolation */ export class ExecutionLogService { /** Log file path for this session - stored in system temp directory */ private readonly logFile: string; /** In-memory cache of recent executions (last 1000 entries) */ private cache: LogEntry[] | null = null; /** Max cache size to prevent memory bloat */ private static readonly MAX_CACHE_SIZE = 1000; /** Session ID for this service instance */ private readonly sessionId: string; /** * Create a new ExecutionLogService instance for a specific session * @param sessionId - Unique session identifier */ constructor(sessionId: string) { this.sessionId = sessionId; this.logFile = path.join(os.tmpdir(), `hook-adapter-executions-${sessionId}.jsonl`); } /** * Check if a specific action was already taken for this file in this session * * NOTE: Uses fail-open strategy - on error, returns false to allow action. * This ensures hooks can still provide guidance even if log access fails. * * @param params - Parameters for checking execution * @returns true if the action was already taken, false on error (fail-open) */ async hasExecuted(params: HasExecutedParams): Promise<boolean> { const { filePath, decision, filePattern, projectPath } = params; try { const entries = await this.loadLog(); // Search from end (most recent) for efficiency for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; const matchedFile = (entry.filePattern === filePattern && entry.filePattern && filePattern) || entry.filePath === filePath; // Match project path if provided (allows same patterns in different projects) const matchedProject = !projectPath || entry.projectPath === projectPath; // Match file, decision, and project (session is already filtered by log file) if (entry.decision === decision && matchedFile && matchedProject) { return true; } } return false; } catch (error: unknown) { // Fail-open: on error, return false to allow action // This ensures hooks can still provide guidance even if log access fails console.error(`Error checking execution for ${filePath}:`, error); return false; } } /** * Log a hook execution * * NOTE: This method uses fail-silent strategy. Logging failures should never * break hook execution since the hook's primary purpose is to provide guidance, * not to persist data. The log is used for optimization (preventing duplicate * guidance) rather than critical functionality. * * @param params - Log execution parameters (sessionId will be set automatically) */ async logExecution(params: Omit<LogExecutionParams, 'sessionId'>): Promise<void> { const entry: LogEntry = { timestamp: Date.now(), sessionId: this.sessionId, filePath: params.filePath, operation: params.operation, decision: params.decision, filePattern: params.filePattern, fileMtime: params.fileMtime, fileChecksum: params.fileChecksum, generatedFiles: params.generatedFiles, scaffoldId: params.scaffoldId, projectPath: params.projectPath, featureName: params.featureName, }; // Append to log file (JSONL format - one JSON object per line) try { await fs.appendFile(this.logFile, `${JSON.stringify(entry)}\n`, 'utf-8'); // Update cache if (this.cache) { this.cache.push(entry); // Trim cache if too large if (this.cache.length > ExecutionLogService.MAX_CACHE_SIZE) { this.cache = this.cache.slice(-ExecutionLogService.MAX_CACHE_SIZE); } } } catch (error) { // Fail-silent: logging is non-critical - hooks should continue even if log fails // This prevents disk full, permission issues, etc. from breaking hook functionality console.error('Failed to log hook execution:', error); } } /** * Load execution log from file * Uses in-memory cache for performance * * NOTE: Uses fail-silent strategy for non-ENOENT errors. The log is used for * optimization (deduplication) rather than critical functionality. If the log * cannot be read, returning empty allows hooks to continue with potentially * duplicate guidance rather than failing entirely. */ async loadLog(): Promise<LogEntry[]> { // Return cached data if available if (this.cache !== null) { return this.cache; } try { // Read log file const content = await fs.readFile(this.logFile, 'utf-8'); // Parse JSONL format const lines = content.trim().split('\n').filter(Boolean); const entries: LogEntry[] = []; for (const line of lines) { try { entries.push(JSON.parse(line) as LogEntry); } catch (parseError) { // Skip malformed entries, log for debugging console.warn('Skipping malformed log entry:', line.substring(0, 100)); } } // Keep only recent entries to prevent memory bloat this.cache = entries.slice(-ExecutionLogService.MAX_CACHE_SIZE); return this.cache; } catch (error: unknown) { // File doesn't exist yet - expected for new sessions if (isNodeError(error) && error.code === 'ENOENT') { this.cache = []; return this.cache; } // Other errors - fail-silent to allow hooks to continue // This prevents permission issues, disk errors from breaking functionality console.error(`Failed to load execution log from ${this.logFile}:`, error); this.cache = []; return this.cache; } } /** * Clear the execution log (for testing) * @throws Error if deletion fails for reasons other than file not existing */ async clearLog(): Promise<void> { try { await fs.unlink(this.logFile); this.cache = []; } catch (error: unknown) { // File doesn't exist - already cleared, nothing to do if (isNodeError(error) && error.code === 'ENOENT') { this.cache = []; return; } // Re-throw with context for unexpected errors throw new Error( `Failed to clear execution log at ${this.logFile}: ${error instanceof Error ? error.message : 'Unknown error'}`, ); } } /** * Get log statistics (for debugging) * * NOTE: Uses fail-open strategy - on error, returns zero counts. * This is acceptable for debugging statistics which are non-critical. * * @returns Log statistics, or zeros on error (fail-open) */ async getStats(): Promise<LogStats> { try { const entries = await this.loadLog(); const files = new Set(entries.map((e) => e.filePath)); return { totalEntries: entries.length, uniqueFiles: files.size, }; } catch (error: unknown) { // Fail-open: return zeros for debugging stats on error console.error('Error getting log stats:', error); return { totalEntries: 0, uniqueFiles: 0, }; } } /** * Get file metadata (mtime and checksum) for a file * * @param filePath - Path to the file * @returns File metadata or null if file doesn't exist */ async getFileMetadata(filePath: string): Promise<{ mtime: number; checksum: string } | null> { try { // Read file content first to compute checksum - this is the authoritative value // for detecting changes. We get mtime after for optimization purposes. // Note: There's a theoretical race between read and stat, but the checksum // (computed from actual content read) is what we use for change detection. const content = await fs.readFile(filePath, 'utf-8'); const checksum = crypto.createHash('md5').update(content).digest('hex'); const stats = await fs.stat(filePath); return { mtime: stats.mtimeMs, checksum, }; } catch (error: unknown) { // Expected: file doesn't exist (ENOENT) if (isNodeError(error) && error.code === 'ENOENT') { return null; } // Unexpected errors - log for debugging console.warn(`Failed to get file metadata for ${filePath}:`, error); return null; } } /** * Check if a file has changed since the last execution for this session * Returns true if the file should be reviewed (new file or content changed) * * NOTE: Uses fail-open strategy - on error, returns true to allow review. * This ensures hooks can still provide value even if log access fails. * * @param filePath - File path to check * @param decision - Decision type to check for * @returns true if file has changed or no previous execution found, true on error (fail-open) */ async hasFileChanged(filePath: string, decision: string): Promise<boolean> { try { const entries = await this.loadLog(); // Find the most recent execution for this file/decision let lastExecution: LogEntry | null = null; for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; if (entry.filePath === filePath && entry.decision === decision) { lastExecution = entry; break; } } // No previous execution - file should be reviewed if (!lastExecution || !lastExecution.fileChecksum) { return true; } // Get current file metadata const currentMetadata = await this.getFileMetadata(filePath); if (!currentMetadata) { return true; // File doesn't exist, let hook handle it } // Compare checksum - if different, file has changed return currentMetadata.checksum !== lastExecution.fileChecksum; } catch (error: unknown) { // Fail-open: on error, return true to allow review // This ensures hooks can still provide value even if log access fails console.error(`Error checking if file changed for ${filePath}:`, error); return true; } } /** * Check if file was recently reviewed (within debounce window) * Prevents noisy feedback during rapid successive edits * * NOTE: Uses fail-open strategy - on error, returns false to allow review. * This ensures hooks can still provide value even if log access fails. * * @param filePath - File path to check * @param debounceMs - Debounce window in milliseconds (default: 3000ms = 3 seconds) * @returns true if file was reviewed within debounce window, false on error (fail-open) */ async wasRecentlyReviewed(filePath: string, debounceMs = 3000): Promise<boolean> { try { const entries = await this.loadLog(); const now = Date.now(); // Search from end (most recent) for efficiency for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; // Match file and check if it's a review operation (has fileMtime or fileChecksum) // and has decision 'allow' or 'deny' (actual reviews, not skips) const isReviewOperation = entry.fileMtime !== undefined || entry.fileChecksum !== undefined; const isReviewDecision = entry.decision === 'allow' || entry.decision === 'deny'; if (entry.filePath === filePath && isReviewOperation && isReviewDecision) { // Check if this review was recent (within debounce window) const timeSinceLastReview = now - (entry.timestamp ?? 0); if (timeSinceLastReview < debounceMs) { return true; } // Found an entry but it's old enough - allow review return false; } } // No previous review found - allow review return false; } catch (error: unknown) { // Fail-open: on error, allow review to proceed // This ensures hooks can still provide value even if log access fails console.error(`Error checking recent review for ${filePath}:`, error); return false; } } /** * Check if a file was generated by a scaffold method * Useful for hooks to avoid suggesting scaffold for files already created by scaffold * * NOTE: Uses fail-open strategy - on error, returns false to allow scaffold suggestion. * Worst case: user sees scaffold suggestion for an already-scaffolded file. * * @param filePath - File path to check * @returns true if file was generated by scaffold in this session, false on error (fail-open) */ async wasGeneratedByScaffold(filePath: string): Promise<boolean> { try { const entries = await this.loadLog(); // Search from end (most recent) for efficiency for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; // Only check scaffold operations if (entry.operation === 'scaffold') { // Check if this file is in the generatedFiles list if (entry.generatedFiles && entry.generatedFiles.includes(filePath)) { return true; } } } return false; } catch (error: unknown) { // Fail-open: on error, return false to allow scaffold suggestion // Worst case is duplicate scaffold suggestion, which is acceptable console.error(`Error checking if file was generated by scaffold for ${filePath}:`, error); return false; } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/AgiFlow/aicode-toolkit'

If you have feedback or need assistance with the MCP directory API, please join our Discord server