← back to Letsbegin

app/api/ralph/run/route.ts

309 lines

import { NextRequest, NextResponse } from 'next/server'
import { spawn, ChildProcess } from 'child_process'
import * as fs from 'fs'
import * as path from 'path'

// State file path for cross-request persistence
const STATE_FILE = '/tmp/ralph-state.json'

interface RalphState {
  running: boolean
  projectPath: string
  branchName: string
  startTime: number
  currentStoryIndex: number
  logs: string[]
  storyResults: Record<string, 'pending' | 'running' | 'pass' | 'fail'>
  storyTimes: Record<string, number>
  storyNotes: Record<string, string>
  userStories: any[]
  completed: boolean
  pid?: number
}

function getState(): RalphState {
  if (fs.existsSync(STATE_FILE)) {
    return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'))
  }
  return {
    running: false,
    projectPath: '',
    branchName: '',
    startTime: 0,
    currentStoryIndex: 0,
    logs: [],
    storyResults: {},
    storyTimes: {},
    storyNotes: {},
    userStories: [],
    completed: false
  }
}

function saveState(state: RalphState) {
  fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
}

function addLog(state: RalphState, message: string) {
  state.logs.push(message)
  if (state.logs.length > 500) {
    state.logs = state.logs.slice(-500)
  }
  saveState(state)
}

export async function POST(request: NextRequest) {
  try {
    const { ralphData, projectPath } = await request.json()

    if (!ralphData || !projectPath) {
      return NextResponse.json({ error: 'Missing ralphData or projectPath' }, { status: 400 })
    }

    // Initialize state
    const state: RalphState = {
      running: true,
      projectPath,
      branchName: ralphData.branchName,
      startTime: Date.now(),
      currentStoryIndex: 0,
      logs: [],
      storyResults: {},
      storyTimes: {},
      storyNotes: {},
      userStories: ralphData.userStories,
      completed: false
    }

    // Initialize story results
    ralphData.userStories.forEach((story: any) => {
      state.storyResults[story.id] = 'pending'
    })

    addLog(state, `> Ralphy Boy starting...`)
    addLog(state, `> Project: ${projectPath}`)
    addLog(state, `> Branch: ${ralphData.branchName}`)
    addLog(state, `> Stories: ${ralphData.userStories.length}`)
    addLog(state, '')

    // Create scripts/ralph directory if needed
    const ralphDir = path.join(projectPath, 'scripts', 'ralph')
    if (!fs.existsSync(ralphDir)) {
      fs.mkdirSync(ralphDir, { recursive: true })
    }

    // Save prd.json
    const prdPath = path.join(ralphDir, 'prd.json')
    fs.writeFileSync(prdPath, JSON.stringify(ralphData, null, 2))
    addLog(state, `> Saved prd.json to ${prdPath}`)

    // Create progress.txt
    const progressPath = path.join(ralphDir, 'progress.txt')
    const progressContent = generateProgressFile(ralphData)
    fs.writeFileSync(progressPath, progressContent)
    addLog(state, `> Created progress.txt`)

    // Ensure git repo exists (auto-init if needed)
    const { execSync } = require('child_process')
    try {
      execSync(`git rev-parse --git-dir`, { cwd: projectPath, stdio: 'pipe' })
      addLog(state, `> Git repo detected`)
    } catch {
      addLog(state, `> No git repo found, initializing...`)
      try {
        // Add to safe directories first
        execSync(`git config --global --add safe.directory "${projectPath}"`, { stdio: 'pipe' })
        // Initialize git repo
        execSync(`git init`, { cwd: projectPath, stdio: 'pipe' })
        execSync(`git add .`, { cwd: projectPath, stdio: 'pipe' })
        execSync(`git commit -m "Initial commit"`, { cwd: projectPath, stdio: 'pipe' })
        addLog(state, `> Git repo initialized with initial commit`)

        // Try to create GitHub repo using gh CLI
        try {
          const projectName = path.basename(projectPath)
          execSync(`gh repo create ${projectName} --private --source=. --push`, { cwd: projectPath, stdio: 'pipe' })
          addLog(state, `> GitHub repo created: ${projectName}`)
        } catch (ghErr: any) {
          addLog(state, `> GitHub repo not created (local only): ${ghErr.message}`)
        }
      } catch (gitErr: any) {
        addLog(state, `> Git init warning: ${gitErr.message}`)
      }
    }

    // Create git branch
    try {
      try {
        execSync(`git rev-parse --verify ${ralphData.branchName}`, { cwd: projectPath, stdio: 'pipe' })
        addLog(state, `> Branch ${ralphData.branchName} already exists, checking out...`)
        execSync(`git checkout ${ralphData.branchName}`, { cwd: projectPath, stdio: 'pipe' })
      } catch {
        addLog(state, `> Creating branch: ${ralphData.branchName}`)
        execSync(`git checkout -b ${ralphData.branchName}`, { cwd: projectPath, stdio: 'pipe' })
      }
    } catch (err: any) {
      addLog(state, `> Git warning: ${err.message}`)
    }

    addLog(state, '')
    addLog(state, `> Starting Ralphy Boy loop...`)
    addLog(state, `> This may take several minutes per story.`)
    addLog(state, '')

    // Save the prompt to a file to avoid shell escaping issues
    const promptPath = '/tmp/ralph-prompt.txt'
    const promptContent = buildRalphPrompt(ralphData)
    fs.writeFileSync(promptPath, promptContent)

    // Create the Ralph runner script with proper loop
    const scriptPath = '/tmp/ralph-runner.sh'
    const logPath = '/tmp/ralph-output.log'

    // Ensure ralph user can write to the project
    try {
      execSync(`chown -R ralph:ralph "${projectPath}"`, { stdio: 'pipe' })
    } catch {}

    const script = `#!/bin/bash
set -e

cd "${projectPath}"
export HOME="/home/ralph"
export NVM_DIR="/home/ralph/.nvm"
export PATH="/home/ralph/.nvm/versions/node/v22.19.0/bin:$PATH"
export ANTHROPIC_API_KEY="${process.env.ANTHROPIC_API_KEY || ''}"

PROGRESS_FILE="${path.join(ralphDir, 'progress.txt')}"
LOG_FILE="${logPath}"

# Clear log file
> "$LOG_FILE"

echo "=== RALPHY BOY STARTING ===" >> "$LOG_FILE"
echo "Project: ${projectPath}" >> "$LOG_FILE"
echo "Branch: ${ralphData.branchName}" >> "$LOG_FILE"
echo "Running as: $(whoami)" >> "$LOG_FILE"
echo "" >> "$LOG_FILE"

# Loop through each story
${ralphData.userStories.map((story: any, idx: number) => `
# Story ${story.id}
echo "=== Starting ${story.id}: ${story.title.replace(/"/g, '\\"')} ===" >> "$LOG_FILE"
sed -i 's/${story.id}: PENDING/${story.id}: RUNNING/' "$PROGRESS_FILE"

# Create story-specific prompt
cat > /tmp/ralph-story-${story.id}.txt << 'STORYPROMPT'
You are Ralphy Boy. Implement this ONE story for project ${ralphData.project}:

STORY: ${story.id} - ${story.title}
DESCRIPTION: ${story.description}

ACCEPTANCE CRITERIA:
${story.acceptanceCriteria.map((c: string) => `- ${c}`).join('\n')}

INSTRUCTIONS:
1. Implement only this story
2. Run typecheck: npm run build (or tsc --noEmit)
3. Commit: git add . && git commit -m "feat(${story.id}): ${story.title.replace(/"/g, '\\"')}"
4. Announce when done: "Completed ${story.id}"

Start implementation now.
STORYPROMPT

# Run Gemini-driven runner for this story (no Claude API hits)
echo "Running Gemini for ${story.id}..." >> "$LOG_FILE"
if /home/ralph/.nvm/versions/node/v22.19.0/bin/node ${process.cwd()}/scripts/ralph-llm-runner.js --prompt-file /tmp/ralph-story-${story.id}.txt --project "${projectPath}" >> "$LOG_FILE" 2>&1; then
  echo "Completed ${story.id}" >> "$LOG_FILE"
  sed -i 's/${story.id}: RUNNING/${story.id}: DONE/' "$PROGRESS_FILE"
else
  echo "Failed ${story.id}" >> "$LOG_FILE"
  sed -i 's/${story.id}: RUNNING/${story.id}: FAIL/' "$PROGRESS_FILE"
fi
echo "" >> "$LOG_FILE"
`).join('\n')}

echo "=== RALPH_COMPLETE ===" >> "$LOG_FILE"
`
    fs.writeFileSync(scriptPath, script)
    fs.chmodSync(scriptPath, '755')

    // Clear previous log
    if (fs.existsSync(logPath)) {
      fs.unlinkSync(logPath)
    }

    // Spawn the process in background as ralph user (non-root) using sudo
    const proc = spawn('sudo', ['-u', 'ralph', 'bash', scriptPath], {
      detached: true,
      stdio: 'ignore',
      cwd: projectPath,
      env: {
        ...process.env,
        HOME: '/home/ralph',
        PATH: '/home/ralph/.nvm/versions/node/v22.19.0/bin:' + process.env.PATH,
        ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || ''
      }
    })
    proc.unref()

    state.pid = proc.pid
    saveState(state)

    return NextResponse.json({
      success: true,
      message: 'Ralph started',
      branchName: ralphData.branchName,
      pid: proc.pid
    })

  } catch (error: any) {
    console.error('Ralph run error:', error)
    return NextResponse.json({ error: error.message }, { status: 500 })
  }
}

function buildRalphPrompt(ralphData: any): string {
  const stories = ralphData.userStories.map((s: any) => {
    const criteria = s.acceptanceCriteria.map((c: string) => `    - ${c}`).join('\n')
    return `${s.id}: ${s.title}
  Description: ${s.description}
  Priority: ${s.priority}
  Acceptance Criteria:
${criteria}`
  }).join('\n\n')

  return `You are Ralphy Boy, an autonomous development agent. Implement these user stories for the project.

PROJECT: ${ralphData.project}
BRANCH: ${ralphData.branchName}

USER STORIES:
${stories}

INSTRUCTIONS:
1. Work through each story in priority order
2. For each story: announce "Starting US-XXX", implement, then announce "Completed US-XXX"
3. Update scripts/ralph/progress.txt with "US-XXX: DONE" after each
4. Commit after each story: git commit -m "feat(US-XXX): title"
5. Run typecheck after changes
6. If a story fails, mark "US-XXX: FAIL" and continue

Begin now.`
}

function generateProgressFile(ralphData: any): string {
  const header = `# Ralph Progress
# Project: ${ralphData.project}
# Branch: ${ralphData.branchName}
# Started: ${new Date().toISOString()}

## Stories
`
  const stories = ralphData.userStories.map((s: any) =>
    `${s.id}: PENDING - ${s.title}`
  ).join('\n')

  return header + stories + '\n\n## Log\n'
}