#!/usr/bin/env node /** * Level 2: Apply Recommendation (Manual) * * 사람이 승인 후 파라미터 조정을 수동으로 적용 * - Rollback point 자동 생성 * - Atomic file operations * - Change log 기록 */ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); // ============================================================================ // Configuration // ============================================================================ const CONFIG = { recommendationsPath: path.join(process.env.HOME, 'openclaw/logs/level2/recommendations-latest.json'), wrapperDir: path.join(process.env.HOME, 'openclaw/scripts'), backupDir: path.join(process.env.HOME, 'openclaw/backups/level2'), changeLogPath: path.join(process.env.HOME, 'openclaw/logs/level2/changes.jsonl') }; // Mapping from cron name to wrapper file const WRAPPER_MAP = { 'TQQQ 15분 모니터링': 'tqqq-monitor-with-retry.js', 'GitHub 감시': 'github-watcher-with-retry.js', '일일 주식 브리핑': 'stock-briefing-with-retry.js', '시장 급변 감지': 'tqqq-monitor-with-retry.js' // Same as TQQQ }; // ============================================================================ // Helper Functions // ============================================================================ function loadRecommendations() { if (!fs.existsSync(CONFIG.recommendationsPath)) { throw new Error(`Recommendations file not found: ${CONFIG.recommendationsPath}`); } const data = fs.readFileSync(CONFIG.recommendationsPath, 'utf8'); const json = JSON.parse(data); if (!json.recommendations || json.recommendations.length === 0) { throw new Error('No recommendations found'); } return json; } function getWrapperPath(cron) { const wrapperFile = WRAPPER_MAP[cron]; if (!wrapperFile) { throw new Error(`Unknown cron: ${cron}. Add to WRAPPER_MAP.`); } return path.join(CONFIG.wrapperDir, wrapperFile); } function createBackup(wrapperPath) { // Ensure backup directory exists if (!fs.existsSync(CONFIG.backupDir)) { fs.mkdirSync(CONFIG.backupDir, { recursive: true }); } const timestamp = Date.now(); const wrapperName = path.basename(wrapperPath); const backupPath = path.join(CONFIG.backupDir, `${wrapperName}.${timestamp}.bak`); // Create backup fs.copyFileSync(wrapperPath, backupPath); return { timestamp, backupPath, wrapperPath, wrapperName }; } function applyChange(recommendation, wrapperPath) { const content = fs.readFileSync(wrapperPath, 'utf8'); // Different replacement strategies based on parameter let updated; if (recommendation.param === 'maxRetries') { // Match: MAX_RETRIES: 3 (in CONFIG object) updated = content.replace( /MAX_RETRIES:\s*\d+/g, `MAX_RETRIES: ${recommendation.proposed}` ); } else if (recommendation.param === 'timeout') { // Match: timeout: 15000 (in spawnSync options) updated = content.replace( /timeout:\s*\d+/g, `timeout: ${recommendation.proposed}` ); } else if (recommendation.param === 'backoffBase') { // Match: BACKOFF_BASE: 1000 or baseDelay: 1000 updated = content.replace( /(BACKOFF_BASE|baseDelay):\s*\d+/g, `$1: ${recommendation.proposed}` ); } else { throw new Error(`Unknown parameter: ${recommendation.param}`); } // Check if anything changed if (updated === content) { console.warn(`⚠️ Warning: No changes detected. Pattern may not match.`); console.warn(` Looking for: ${recommendation.param}: ${recommendation.current}`); } // Atomic write: write to temp file, then rename const tempPath = `${wrapperPath}.tmp`; fs.writeFileSync(tempPath, updated, 'utf8'); fs.renameSync(tempPath, wrapperPath); return { success: true, changes: updated !== content }; } function logChange(recommendation, backup) { const entry = { timestamp: new Date().toISOString(), cron: recommendation.cron, param: recommendation.param, from: recommendation.current, to: recommendation.proposed, reason: recommendation.reason, expectedImprovement: recommendation.expectedImprovement, severity: recommendation.severity, confidence: recommendation.confidence, backup: backup.backupPath, appliedBy: 'manual', user: process.env.USER || 'unknown' }; // Ensure log directory exists const logDir = path.dirname(CONFIG.changeLogPath); if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); } // Append to JSONL fs.appendFileSync(CONFIG.changeLogPath, JSON.stringify(entry) + '\n'); return entry; } function reloadGateway() { try { // Check if openclaw gateway is running const result = execSync('pgrep -f "openclaw.*gateway" || echo "not_running"', { encoding: 'utf8' }); if (result.trim() === 'not_running') { console.log('ℹ️ OpenClaw Gateway not running, skipping reload'); return; } // Gateway is running - it should auto-reload file changes // Just log for now console.log('ℹ️ OpenClaw Gateway will auto-detect file changes on next cron run'); } catch (error) { console.warn('⚠️ Could not check gateway status:', error.message); } } // ============================================================================ // Apply Functions // ============================================================================ async function applySingle(id, dryRun = false) { const data = loadRecommendations(); const recommendation = data.recommendations[id]; if (!recommendation) { throw new Error(`Recommendation #${id} not found (available: 0-${data.recommendations.length - 1})`); } console.log('╔═══════════════════════════════════════════════════════════╗'); console.log('║ 🔧 Applying Recommendation ║'); console.log('╚═══════════════════════════════════════════════════════════╝\n'); console.log(`ID: #${id}`); console.log(`Cron: ${recommendation.cron}`); console.log(`Parameter: ${recommendation.param}`); console.log(`Change: ${recommendation.current} → ${recommendation.proposed}`); console.log(`Reason: ${recommendation.reason}`); console.log(`Expected: ${recommendation.expectedImprovement}`); console.log(`Severity: ${recommendation.severity}`); console.log(`Confidence: ${recommendation.confidence}`); console.log(`Safe: ${recommendation.safe ? '✅ Yes' : '⚠️ Manual review required'}`); console.log(''); if (recommendation.warning) { console.log(`⚠️ WARNING: ${recommendation.warning}`); console.log(''); } if (dryRun) { console.log('🔍 DRY RUN - No changes will be made\n'); return; } // Get wrapper path const wrapperPath = getWrapperPath(recommendation.cron); console.log(`Wrapper: ${wrapperPath}`); console.log(''); // Confirm if (!process.argv.includes('--yes')) { console.log('⚠️ This will modify the wrapper script.'); console.log(' To proceed without confirmation, use --yes flag'); console.log(''); throw new Error('User confirmation required (use --yes to skip)'); } // Create backup console.log('📦 Creating backup...'); const backup = createBackup(wrapperPath); console.log(` ✅ Backup: ${backup.backupPath}\n`); // Apply change console.log('✏️ Applying change...'); const result = applyChange(recommendation, wrapperPath); if (!result.changes) { console.log(' ⚠️ No changes detected (pattern may not match)\n'); } else { console.log(' ✅ Change applied\n'); } // Log change console.log('📝 Logging change...'); const logEntry = logChange(recommendation, backup); console.log(` ✅ Logged to: ${CONFIG.changeLogPath}\n`); // Reload gateway reloadGateway(); console.log('╔═══════════════════════════════════════════════════════════╗'); console.log('║ ✅ Recommendation Applied ║'); console.log('╚═══════════════════════════════════════════════════════════╝\n'); console.log('Next steps:'); console.log(' 1. Monitor auto-retry logs for 24-48 hours'); console.log(' 2. Check for improvements (retry rate, failure rate)'); console.log(' 3. Rollback if needed:'); console.log(` cp ${backup.backupPath} ${wrapperPath}`); console.log(''); } async function applyAllSafe(dryRun = false) { const data = loadRecommendations(); const safeRecs = data.recommendations.filter(r => r.safe); if (safeRecs.length === 0) { console.log('✅ No safe recommendations to apply\n'); return; } console.log(`🔧 Applying ${safeRecs.length} safe recommendation(s)...\n`); for (let i = 0; i < data.recommendations.length; i++) { const rec = data.recommendations[i]; if (rec.safe) { console.log(`─── Recommendation #${i} ───\n`); await applySingle(i, dryRun); console.log(''); } } console.log('✅ All safe recommendations applied\n'); } async function listRecommendations() { const data = loadRecommendations(); console.log('╔═══════════════════════════════════════════════════════════╗'); console.log('║ 💡 Available Recommendations ║'); console.log('╚═══════════════════════════════════════════════════════════╝\n'); console.log(`Analysis Date: ${new Date(data.timestamp).toLocaleString()}`); console.log(`Total: ${data.recommendations.length} recommendation(s)\n`); data.recommendations.forEach((rec, i) => { const icon = rec.severity === 'high' ? '🔴' : rec.severity === 'medium' ? '🟡' : '🟢'; const safeIcon = rec.safe ? '✅' : '⚠️'; console.log(`${i}. ${icon} ${rec.cron}`); console.log(` ${safeIcon} ${rec.param}: ${rec.current} → ${rec.proposed}`); console.log(` ${rec.reason}`); console.log(` Confidence: ${rec.confidence}, Severity: ${rec.severity}`); console.log(''); }); console.log('To apply:'); console.log(` node ${__filename} --id=0 --yes`); console.log(` node ${__filename} --all-safe --yes`); console.log(''); } // ============================================================================ // CLI // ============================================================================ async function main() { const args = process.argv.slice(2); // Parse arguments const idArg = args.find(a => a.startsWith('--id=')); const allSafe = args.includes('--all-safe'); const dryRun = args.includes('--dry-run'); const list = args.includes('--list') || args.length === 0; try { if (list) { await listRecommendations(); } else if (idArg) { const id = parseInt(idArg.split('=')[1]); if (isNaN(id)) { throw new Error('Invalid ID (must be number)'); } await applySingle(id, dryRun); } else if (allSafe) { await applyAllSafe(dryRun); } else { console.error('Usage:'); console.error(' --list List all recommendations (default)'); console.error(' --id=N --yes Apply recommendation #N'); console.error(' --all-safe --yes Apply all safe recommendations'); console.error(' --dry-run Preview changes without applying'); process.exit(1); } } catch (error) { console.error('❌ Error:', error.message); process.exit(1); } } // ============================================================================ // Run // ============================================================================ if (require.main === module) { main(); } module.exports = { applySingle, applyAllSafe, listRecommendations };