/** * FunctionalHarness - TEA-inspired functional state management * * @description Implements The Elm Architecture (TEA) principles for managing * script execution, state flow, and command processing. Provides automatic * versioning, timeout protection, and error handling while maintaining * functional purity in script execution. * * Architecture: * - Model: Current state (pure table data) * - Update: Pure function (State → { model, commands, version }) * - Commands: Side effects processed by adapters * - View: External system integration via adapters */ // Import dependencies import { StateHistory } from './history.js'; import { ScriptEnvironment } from './environment.js'; class FunctionalHarness { constructor(scriptPathOrContent, config = {}) { // Handle both file paths and string content // If it's a string and looks like a file path (no semicolons, contains slashes), treat as path const isFilePath = typeof scriptPathOrContent === 'string' && !scriptPathOrContent.includes(';') && (scriptPathOrContent.includes('/') || scriptPathOrContent.includes('\\')); this.scriptPath = isFilePath ? scriptPathOrContent : null; this.scriptContent = !isFilePath && typeof scriptPathOrContent === 'string' ? scriptPathOrContent : null; // Default configuration this.config = { maxVersions: 100, // Default version limit enableHistory: true, // Enable state history by default timeout: 5000, // 5 second default timeout debug: false, // Debug mode off by default logStateChanges: false, // State change logging off by default logCommands: false, // Command logging off by default ...config }; // Initialize interpreter lazily this.interpreter = null; this.stateHistory = new StateHistory(this.config.maxVersions); this.currentVersion = 0; } /** * Initialize the interpreter (called lazily) */ async initialize() { if (!this.interpreter) { // Try different possible paths for lang.js // The harness is in scripting-harness/core/, so we need to go up to find lang.js const possiblePaths = [ '../../lang.js', // From scripting-harness/core/ to root '../../../lang.js', // Fallback './lang.js', // Current directory '../lang.js', // Parent directory '../../../../lang.js' // Extra fallback ]; let lastError = null; for (const path of possiblePaths) { try { this.interpreter = await import(path); break; } catch (error) { lastError = error; // Continue to next path } } if (!this.interpreter) { throw new Error(`Could not find lang.js interpreter. Tried paths: ${possiblePaths.join(', ')}. Last error: ${lastError?.message}`); } } return this.interpreter; } /** * Pure function: State → { model, commands, version } * * @param {Object} currentState - Current state (pure table data) * @returns {Promise} Promise resolving to { model, commands, version } */ async update(currentState) { try { // Create new version with metadata wrapper const newVersion = this.currentVersion + 1; const versionedState = { data: currentState, // Pure table data version: newVersion, // Metadata timestamp: Date.now() // Metadata }; // Log state changes in debug mode if (this.config.logStateChanges) { console.log(`[Harness] State update to version ${newVersion}:`, versionedState); } // Set up script environment const environment = new ScriptEnvironment(versionedState); // Run script as pure function with timeout protection const result = await this.runScript(environment); // Add to history this.stateHistory.addVersion(newVersion, versionedState, result.model); this.currentVersion = newVersion; const commands = environment.getCommands(); // Log commands in debug mode if (this.config.logCommands && commands.length > 0) { console.log(`[Harness] Commands emitted at version ${newVersion}:`, commands); } // The script result contains the global scope with all variables // We need to extract user-defined variables (excluding standard library functions) let newModel = currentState; if (typeof result.model === 'object' && result.model !== null) { // Filter out standard library functions and keep only user variables const userVariables = {}; const standardLibraryKeys = [ 'map', 'compose', 'curry', 'apply', 'pipe', 'filter', 'reduce', 'fold', 'add', 'subtract', 'multiply', 'divide', 'modulo', 'power', 'negate', 'equals', 'notEquals', 'lessThan', 'greaterThan', 'lessEqual', 'greaterEqual', 'logicalAnd', 'logicalOr', 'logicalXor', 'logicalNot', 'identity', 'constant', 'flip', 'on', 'both', 'either', 'each', 't' ]; for (const [key, value] of Object.entries(result.model)) { if (!standardLibraryKeys.includes(key)) { userVariables[key] = value; } } newModel = { ...currentState, ...userVariables }; } return { model: newModel, commands: commands, version: newVersion }; } catch (error) { // Return error state instead of crashing const errorCommand = { type: 'error', error: error.message, errorType: this.classifyError(error), version: this.currentVersion, state: currentState }; return { model: currentState, commands: [errorCommand], version: this.currentVersion }; } } /** * Classify errors for recovery strategies * * @param {Error} error - Error to classify * @returns {string} Error classification */ classifyError(error) { const message = error.message.toLowerCase(); // Script execution errors if (message.includes('unexpected token') || message.includes('syntax error') || message.includes('parse') || message.includes('lexer')) { return 'script_error'; } // Timeout errors if (message.includes('timeout') || message.includes('timed out')) { return 'timeout_error'; } // Network errors if (message.includes('network') || message.includes('fetch') || message.includes('http') || message.includes('connection') || message.includes('econnrefused') || message.includes('enotfound')) { return 'network_error'; } // File system errors if (message.includes('file') || message.includes('fs') || message.includes('enoent') || message.includes('eperm')) { return 'filesystem_error'; } // Memory errors if (message.includes('memory') || message.includes('heap') || message.includes('out of memory')) { return 'memory_error'; } // Default to unknown error return 'unknown_error'; } /** * Process commands (side effects) * * @param {Array} commands - Array of command objects * @param {Object} context - Context for command processing * @returns {Promise} Promise resolving to command results */ async processCommands(commands, context = {}) { const results = []; for (const command of commands) { switch (command.type) { case 'emit': results.push(await this.handleEmit(command.value, context)); break; case 'error': results.push(await this.handleError(command.error, context)); break; default: results.push(await this.handleUnknownCommand(command, context)); } } return results; } /** * Main processing loop * * @param {Object} newState - New state to process * @param {Object} context - Context for processing * @returns {Promise} Promise resolving to { model, commands, results, version } */ async processState(newState, context = {}) { const { model, commands, version } = await this.update(newState); const results = await this.processCommands(commands, context); return { model, commands, results, version }; } /** * Rollback to specific version * * @param {number} targetVersion - Version to rollback to * @returns {Object} Historical state */ async rollbackToVersion(targetVersion) { const historicalState = this.stateHistory.getVersion(targetVersion); if (!historicalState) { throw new Error(`Version ${targetVersion} not found`); } this.currentVersion = targetVersion; return historicalState; } /** * Get version history * * @returns {Array} Array of version metadata */ getVersionHistory() { return this.stateHistory.getAllVersions(); } /** * Create branch from specific version * * @param {number} fromVersion - Base version * @param {string} branchName - Branch name * @returns {FunctionalHarness} New harness instance */ async createBranch(fromVersion, branchName) { const baseState = this.stateHistory.getVersion(fromVersion); if (!baseState) { throw new Error(`Version ${fromVersion} not found`); } // Create new harness with branch configuration const branchHarness = new FunctionalHarness(this.scriptPath || this.scriptContent, { ...this.config, branchName, baseVersion: fromVersion, parentHarness: this }); // Initialize the branch harness await branchHarness.initialize(); // Set the initial state to the base version branchHarness.currentVersion = fromVersion; branchHarness.stateHistory = this.stateHistory; // Share history console.log(`[Harness] Created branch '${branchName}' from version ${fromVersion}`); return branchHarness; } /** * Get branch information * * @returns {Object} Branch metadata */ getBranchInfo() { return { branchName: this.config.branchName || 'main', baseVersion: this.config.baseVersion || 0, currentVersion: this.currentVersion, parentHarness: this.config.parentHarness ? true : false }; } /** * Get state diff between versions * * @param {number} fromVersion - Starting version * @param {number} toVersion - Ending version (defaults to current) * @returns {Object|null} Diff object or null if versions not found */ getStateDiff(fromVersion, toVersion = this.currentVersion) { const diff = this.stateHistory.getDiff(fromVersion, toVersion); if (diff) { console.log(`[Harness] State diff from v${fromVersion} to v${toVersion}:`); console.log(` Added: ${Object.keys(diff.added).length} properties`); console.log(` Removed: ${Object.keys(diff.removed).length} properties`); console.log(` Changed: ${Object.keys(diff.changed).length} properties`); } return diff; } /** * Enhanced error recovery with retry mechanism * * @param {Function} operation - Operation to retry * @param {Object} options - Retry options * @returns {Promise} Operation result */ async retryOperation(operation, options = {}) { const { maxRetries = 3, backoff = 2, onRetry = null } = options; let delay = options.delay || 1000; let lastError; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await operation(); } catch (error) { lastError = error; if (attempt === maxRetries) { console.error(`[Harness] Operation failed after ${maxRetries} attempts:`, error.message); throw error; } console.log(`[Harness] Attempt ${attempt} failed, retrying in ${delay}ms...`); if (onRetry) { onRetry(attempt, error); } await new Promise(resolve => setTimeout(resolve, delay)); delay *= backoff; } } } /** * Recover from error state * * @param {Error} error - The error that occurred * @param {Object} context - Error context * @returns {Promise} Recovery result */ async recoverFromError(error, context = {}) { const errorType = this.classifyError(error); console.log(`[Harness] Attempting to recover from ${errorType} error:`, error.message); switch (errorType) { case 'script_error': // For script errors, try to rollback to last known good state if (this.currentVersion > 0) { console.log(`[Harness] Rolling back to version ${this.currentVersion - 1}`); await this.rollbackToVersion(this.currentVersion - 1); return { recovered: true, action: 'rollback', version: this.currentVersion }; } break; case 'timeout_error': // For timeout errors, try with increased timeout console.log(`[Harness] Retrying with increased timeout`); const originalTimeout = this.config.timeout; this.config.timeout *= 2; try { const result = await this.retryOperation(() => this.update(context.lastState || {})); return { recovered: true, action: 'timeout_increase', result }; } finally { this.config.timeout = originalTimeout; } break; case 'network_error': // For network errors, implement exponential backoff console.log(`[Harness] Implementing network error recovery`); return await this.retryOperation( () => this.update(context.lastState || {}), { maxRetries: 5, delay: 2000, backoff: 2 } ); default: console.log(`[Harness] No specific recovery strategy for ${errorType}`); break; } return { recovered: false, error: error.message, errorType }; } /** * Enhanced state replay with error recovery * * @param {number} startVersion - Version to replay from * @param {Object} newState - New state to apply * @returns {Promise} Replay result */ async replayFromVersion(startVersion, newState) { console.log(`[Harness] Replaying from version ${startVersion} with new state`); try { // Get the state at the start version const baseState = this.stateHistory.getVersion(startVersion); if (!baseState) { throw new Error(`Version ${startVersion} not found`); } // Merge the base state with the new state const mergedState = { ...baseState, ...newState }; // Replay the update with error recovery const result = await this.retryOperation( () => this.update(mergedState), { maxRetries: 2, delay: 500 } ); console.log(`[Harness] Replay completed successfully`); return result; } catch (error) { console.error(`[Harness] Replay failed:`, error.message); return await this.recoverFromError(error, { lastState: newState }); } } /** * Run script with timeout protection * * @param {ScriptEnvironment} environment - Script environment * @returns {Promise} Promise resolving to script result */ async runScript(environment) { return new Promise(async (resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('Script execution timeout')); }, this.config.timeout); try { // Initialize interpreter if needed const interpreter = await this.initialize(); // Load script content (file path or string content) const scriptContent = this.scriptContent || await this.loadScriptFromFile(this.scriptPath); const initialState = this.translateToScript(environment.getCurrentState()); // Call the run function from the imported module const result = interpreter.run(scriptContent, initialState, environment); clearTimeout(timeout); resolve({ model: result }); } catch (error) { clearTimeout(timeout); reject(error); } }); } /** * Load script from file (Node.js/Bun) or use string content (browser) * * @param {string} scriptPath - Path to script file * @returns {string} Script content */ async loadScriptFromFile(scriptPath) { if (typeof process !== 'undefined') { // Node.js/Bun environment const fs = await import('fs'); return fs.readFileSync(scriptPath, 'utf8'); } else { // Browser environment - should have scriptContent throw new Error('Script file loading not supported in browser. Use script content instead.'); } } /** * Translate JS state to script format * * @param {Object} jsState - JavaScript state object * @returns {Object} Script-compatible state */ translateToScript(jsState) { return jsState.data || jsState; // Return pure table data } /** * Translate script result to JS format * * @param {Object} scriptState - Script state object * @returns {Object} JavaScript-compatible state with metadata */ translateFromScript(scriptState) { return { data: scriptState, // Pure table data version: this.currentVersion + 1, timestamp: Date.now() }; } /** * Get current state for ..listen * * @returns {Object} Current state */ getCurrentState() { return this.stateHistory.getVersion(this.currentVersion) || {}; } /** * Handle emit commands * * @param {*} value - Emitted value * @param {Object} context - Context * @returns {Promise} Command result */ async handleEmit(value, context) { // Default implementation - can be overridden by adapters return { type: 'emit', value, processed: true }; } /** * Handle error commands * * @param {string} error - Error message * @param {Object} context - Context * @returns {Promise} Command result */ async handleError(error, context) { // Default implementation - can be overridden by adapters console.error('[Harness] Error:', error); return { type: 'error', error, processed: true }; } /** * Handle unknown commands * * @param {Object} command - Command object * @param {Object} context - Context * @returns {Promise} Command result */ async handleUnknownCommand(command, context) { // Default implementation - can be overridden by adapters console.warn('[Harness] Unknown command:', command); return { type: 'unknown', command, processed: false }; } } export { FunctionalHarness };