diff options
Diffstat (limited to 'js/scripting-lang/scripting-harness/core/harness.js')
-rw-r--r-- | js/scripting-lang/scripting-harness/core/harness.js | 599 |
1 files changed, 599 insertions, 0 deletions
diff --git a/js/scripting-lang/scripting-harness/core/harness.js b/js/scripting-lang/scripting-harness/core/harness.js new file mode 100644 index 0000000..313618b --- /dev/null +++ b/js/scripting-lang/scripting-harness/core/harness.js @@ -0,0 +1,599 @@ +/** + * 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<Object>} 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<Array>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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 }; \ No newline at end of file |