OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-lab / lib / tmux-orchestrator.js

219 lines · javascript ref:
import { exec } from 'child_process'
import { promisify } from 'util'
import fs from 'fs'
import path from 'path'
import chalk from 'chalk'
import { SSHManager } from './ssh-manager.js'

const execAsync = promisify(exec)

export class TmuxOrchestrator {
  constructor(testName, agentConfigs, options = {}) {
    this.testName = testName
    this.sessionName = `hyper-lab-${Date.now()}`
    this.agents = new Map()
    this.paneMap = new Map() // agentName -> pane index
    this.logDir = `/tmp/hyper-lab/logs/${this.sessionName}`

    // Create log directory
    fs.mkdirSync(this.logDir, { recursive: true })

    // Initialize SSH managers
    for (const config of agentConfigs) {
      this.agents.set(config.name, new SSHManager(config))
    }
  }

  async connect() {
    console.log(chalk.blue('🔌 Connecting to agents...\n'))
    for (const agent of this.agents.values()) {
      await agent.connect()
    }
  }

  async createSession() {
    console.log(chalk.blue(`🎬 Creating tmux session: ${this.sessionName}\n`))

    // Create tmux session (detached)
    await execAsync(`tmux new-session -d -s ${this.sessionName} -x 200 -y 50`)

    // STEP 1: Create all panes and set up logging
    console.log(chalk.blue('📋 Creating panes...'))
    let paneIdx = 0
    for (const [agentName, agent] of this.agents) {
      if (paneIdx > 0) {
        // Split horizontally for first split, then vertically
        const splitCmd = paneIdx === 1 ? '-h' : '-v'
        await execAsync(`tmux split-window ${splitCmd} -t ${this.sessionName}`)
      }

      // Map agent to pane
      this.paneMap.set(agentName, paneIdx)

      // Setup pane title
      await this.sendKeys(paneIdx, `printf '\\033]2;%s\\033\\\\' '${agentName}'`)

      // Enable logging for this pane
      const logFile = path.join(this.logDir, `${agentName}.log`)
      await execAsync(`tmux pipe-pane -t ${this.sessionName}.${paneIdx} "cat >> ${logFile}"`)

      paneIdx++
    }

    // STEP 2: SSH into remote panes
    console.log(chalk.blue('🔌 Establishing SSH connections...\n'))
    for (const [agentName, agent] of this.agents) {
      const pane = this.paneMap.get(agentName)

      // SSH into agent machine (unless localhost)
      if (agent.config.host !== 'localhost' && agent.config.host !== '127.0.0.1') {
        console.log(chalk.cyan(`  Connecting to ${agentName} (${agent.config.host})...`))

        const sshCmd = `ssh ${agent.config.user}@${agent.config.host}`
        await this.sendKeys(pane, sshCmd)

        // Wait for SSH connection to complete
        // SSH can take time with login messages, npm warnings, etc.
        await this.sleep(4000)

        // Verify connection by sending hostname command and checking result
        const expectedHost = agent.config.host.split('.')[0]
        await this.sendKeys(pane, 'hostname')
        await this.sleep(500)

        const paneContent = await this.capturePane(pane, 20)
        if (!paneContent.includes(expectedHost)) {
          console.log(chalk.yellow(`  ⚠️  Warning: SSH to ${agentName} may not have connected`))
          console.log(chalk.yellow(`     Expected hostname: ${expectedHost}`))
          console.log(chalk.yellow(`     Pane content: ${paneContent.split('\\n').slice(-3).join(' | ')}`))
        } else {
          console.log(chalk.green(`  ✅ Connected to ${agentName} (${expectedHost})`))
        }
      } else {
        console.log(chalk.green(`  ✅ ${agentName} (local)`))
      }
    }
    console.log('')

    // Select tiled layout for agent panes
    await execAsync(`tmux select-layout -t ${this.sessionName} tiled`)

    console.log(chalk.green(`✅ Tmux session created: ${this.sessionName}`))
    console.log(chalk.cyan(`   Attach: tmux attach -t ${this.sessionName}`))
    console.log(chalk.cyan(`   Logs: ${this.logDir}\n`))

    return this.sessionName
  }

  async sendKeys(pane, command, pressEnter = true) {
    const enterKey = pressEnter ? ' Enter' : ''
    const target = `${this.sessionName}.${pane}`
    // Escape special characters for shell
    const escapedCmd = command.replace(/"/g, '\\"')
    await execAsync(`tmux send-keys -t ${target} "${escapedCmd}"${enterKey}`)
  }

  async capturePane(pane, lines = 100) {
    const target = `${this.sessionName}.${pane}`
    const result = await execAsync(`tmux capture-pane -t ${target} -p -S -${lines}`)
    return result.stdout
  }

  async log(message, color = 'blue') {
    const timestamp = new Date().toISOString().substring(11, 19)

    // Log to console only (no orchestrator pane)
    const chalkColor = chalk[color] || chalk.white
    console.log(chalkColor(`[${timestamp}] ${message}`))
  }

  async run(agentName, command, args = [], options = {}) {
    const agent = this.agents.get(agentName)
    if (!agent) {
      throw new Error(`Unknown agent: ${agentName}`)
    }

    const pane = this.paneMap.get(agentName)

    await this.log(`Running on ${agentName}: ${command} ${args.join(' ')}`, 'blue')

    // Build command with isolated storage
    const testRunId = this.sessionName
    const homeDir = `/tmp/hyper-lab-test-${testRunId}-${agentName}`
    const workDir = agent.config.workDir

    // Create home dir and run command
    const fullCmd = `mkdir -p ${homeDir} && cd ${workDir}/hyper-wallet && HOME=${homeDir} bare hyper-wallet-autobase.cjs ${command} ${args.join(' ')}`

    // Send command to pane (visible in tmux)
    await this.sendKeys(pane, fullCmd)

    // Also execute via SSH to get return status
    const result = await agent.exec(fullCmd, { logOutput: false })

    if (result.exitCode !== 0 && !options.allowFailure) {
      await this.log(`❌ Command failed on ${agentName}`, 'red')
      throw new Error(`Command failed: ${command}`)
    }

    await this.log(`✅ Completed on ${agentName}`, 'green')

    return result
  }

  async waitForOutput(agentName, pattern, timeout = 10000) {
    const pane = this.paneMap.get(agentName)
    const start = Date.now()

    await this.log(`⏳ Waiting for "${pattern}" on ${agentName}...`, 'yellow')

    while (Date.now() - start < timeout) {
      const content = await this.capturePane(pane, 50)

      if (content.includes(pattern)) {
        await this.log(`✅ Found "${pattern}" on ${agentName}`, 'green')
        return true
      }

      await this.sleep(500)
    }

    throw new Error(`Timeout waiting for "${pattern}" on ${agentName}`)
  }

  async readLog(agentName) {
    const logFile = path.join(this.logDir, `${agentName}.log`)

    if (!fs.existsSync(logFile)) {
      return ''
    }

    return fs.readFileSync(logFile, 'utf8')
  }

  async sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms))
  }

  assert(condition, message) {
    if (!condition) {
      this.log(`❌ Assertion failed: ${message}`, 'red')
      throw new Error(`Assertion failed: ${message}`)
    }
    this.log(`✅ ${message}`, 'green')
  }

  async cleanup() {
    await this.log('🧹 Cleaning up...', 'blue')

    // Close SSH connections
    for (const agent of this.agents.values()) {
      await agent.disconnect().catch(() => {})
    }

    await this.log('✅ Cleanup complete', 'green')
    console.log(chalk.cyan(`\n📋 Logs saved to: ${this.logDir}`))
    console.log(chalk.cyan(`🎬 Tmux session: ${this.sessionName} (still running - attach to inspect)\n`))
  }
}