hook-runner.js 4.2 KB
/**
 * HookRunner - 脚本化生命周期钩子执行器 (Orchestrator 侧)
 * 
 * 职责:
 * 1. 解析 Agent 配置中的 hooks 字段
 * 2. 注入上下文环境变量 (Context -> Env)
 * 3. 执行外部脚本 (Shell/Node/Python)
 * 4. 处理执行结果 (Block/Non-Block)
 * 5. Timeout 防挂起保护
 * 
 * 全局配置来源:config.hooks.defaultTimeout / config.hooks.timeout
 * Agent 级配置来源:agentConfig.hooks.<hookName> (脚本路径)
 */

const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');

/** 默认超时 30s */
const DEFAULT_TIMEOUT = 30000;

class HookRunner {
  constructor(config, baseDir) {
    this.config = config;
    this.baseDir = baseDir || process.cwd();
    // 全局 Timeout 配置
    this.defaultTimeout = config?.hooks?.defaultTimeout || DEFAULT_TIMEOUT;
    this.timeouts = config?.hooks?.timeout || {};   // { "pre-task": 60000 }
  }

  /**
   * 执行指定的 Hook
   * @param {string} hookName - Hook 名称 (e.g. 'pre-task', 'post-task-success')
   * @param {object} agentConfig - Agent 配置对象
   * @param {object} context - 上下文数据 (用于注入环境变量)
   * @returns {Promise<void>}
   */
  async run(hookName, agentConfig, context = {}) {
    const hookScript = agentConfig.hooks?.[hookName];
    if (!hookScript) {
      return; // 未配置该 Hook,跳过
    }

    const scriptPath = path.resolve(this.baseDir, hookScript);
    if (!fs.existsSync(scriptPath)) {
      console.warn(`[HookRunner] Warning: Hook script not found at ${scriptPath}`);
      return;
    }

    // 准备环境变量
    const env = this._buildEnv(context);
    const timeout = this.timeouts[hookName] || this.defaultTimeout;

    console.log(`[HookRunner] 🟢 Executing ${hookName}: ${hookScript} (timeout: ${timeout}ms)`);
    const startTime = Date.now();

    try {
      await this._spawnScript(scriptPath, env, timeout);
      const duration = Date.now() - startTime;
      console.log(`[HookRunner] ✅ ${hookName} completed in ${duration}ms`);
    } catch (err) {
      const duration = Date.now() - startTime;
      console.error(`[HookRunner] ❌ ${hookName} failed in ${duration}ms: ${err.message}`);

      // 对于阻塞型 Hook (pre-*, post-task-success),失败应抛出异常阻断流程
      // 对于非阻塞型 (post-task-failure, post-session),通常只记录日志
      if (this._isBlocking(hookName)) {
        throw err;
      }
    }
  }

  _isBlocking(hookName) {
    // 默认 pre-* 和 post-task-success 是阻塞的
    return hookName.startsWith('pre-') || hookName === 'post-task-success';
  }

  _buildEnv(context) {
    const env = { ...process.env };

    // 转换 context 为大写下划线格式的环境变量
    // e.g. agentName -> AGENT_NAME
    for (const [key, value] of Object.entries(context)) {
      if (value === undefined || value === null) continue;
      const envKey = key.replace(/([A-Z])/g, '_$1').toUpperCase();
      env[envKey] = typeof value === 'object' ? JSON.stringify(value) : String(value);
    }

    return env;
  }

  _spawnScript(scriptPath, env, timeout) {
    return new Promise((resolve, reject) => {
      const child = spawn(scriptPath, [], {
        env,
        stdio: 'inherit', // 直接将脚本输出打印到主进程
        shell: true       // 支持运行 .sh, .py 等
      });

      let settled = false;

      // Timeout 保护
      const timer = setTimeout(() => {
        if (settled) return;
        settled = true;
        console.error(`[HookRunner] ⏰ TIMEOUT after ${timeout}ms, killing...`);
        child.kill('SIGTERM');
        setTimeout(() => {
          try { if (!child.killed) child.kill('SIGKILL'); } catch { }
        }, 500);
        reject(new Error(`Hook script timed out after ${timeout}ms`));
      }, timeout);

      child.on('close', (code) => {
        clearTimeout(timer);
        if (settled) return;
        settled = true;
        if (code === 0) {
          resolve();
        } else {
          reject(new Error(`Hook script exited with code ${code}`));
        }
      });

      child.on('error', (err) => {
        clearTimeout(timer);
        if (settled) return;
        settled = true;
        reject(err);
      });
    });
  }
}

module.exports = { HookRunner };