about summary refs log tree commit diff stats
path: root/js/scripting-lang/scripting-harness/core/harness.js
diff options
context:
space:
mode:
Diffstat (limited to 'js/scripting-lang/scripting-harness/core/harness.js')
-rw-r--r--js/scripting-lang/scripting-harness/core/harness.js599
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