← 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'
}