Files
Codeman/src/web/routes/ralph-routes.ts
T

514 lines
19 KiB
TypeScript

/**
* @fileoverview Ralph/todo-related routes.
* Ralph tracker config, circuit breaker, fix plan CRUD, ralph prompt writing,
* and the Ralph Loop start endpoint (autonomous task execution).
*/
import { FastifyInstance } from 'fastify';
import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { Session, isExternalCliMode } from '../../session.js';
import { RespawnController } from '../../respawn-controller.js';
import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js';
import { SseEvent } from '../sse-events.js';
import {
autoConfigureRalph,
getAuthUser,
ownerFor,
resolveCasesDir,
sessionCapacityMessage,
SETTINGS_PATH,
findSessionOrFail,
parseBody,
} from '../route-helpers.js';
import { resolveClaudeModeForUsername } from '../../user-store.js';
import { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { buildRalphLoopPrompt } from '../../prompts/index.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
export function registerRalphRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
): void {
// ═══════════════════════════════════════════════════════════════
// Ralph Tracker Configuration & Status
// ═══════════════════════════════════════════════════════════════
// Configure Ralph tracker for a session
app.post('/api/sessions/:id/ralph-config', async (req) => {
const { id } = req.params as { id: string };
const { enabled, completionPhrase, maxIterations, maxTodos, todoExpirationMinutes, reset, disableAutoEnable } =
parseBody(RalphConfigSchema, req.body, 'Invalid request body') as {
enabled?: boolean;
completionPhrase?: string;
maxIterations?: number;
maxTodos?: number;
todoExpirationMinutes?: number;
reset?: boolean | 'full';
disableAutoEnable?: boolean;
};
const session = findSessionOrFail(ctx, id, req);
// Ralph tracker is not supported for external-CLI sessions (opencode/codex)
if (isExternalCliMode(session.mode)) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`Ralph tracker is not supported for ${session.mode} sessions`
);
}
// Handle reset first (before other config)
if (reset) {
if (reset === 'full') {
session.ralphTracker.fullReset();
} else {
session.ralphTracker.reset();
}
}
// Configure auto-enable behavior
if (disableAutoEnable !== undefined) {
if (disableAutoEnable) {
session.ralphTracker.disableAutoEnable();
} else {
session.ralphTracker.enableAutoEnable();
}
}
// Enable/disable the tracker
if (enabled !== undefined) {
if (enabled) {
session.ralphTracker.enable();
// Allow re-enabling on restart if user explicitly enabled
session.ralphTracker.enableAutoEnable();
} else {
session.ralphTracker.disable();
// Prevent re-enabling on restart when user explicitly disabled
session.ralphTracker.disableAutoEnable();
}
// Persist Ralph enabled state
ctx.mux.updateRalphEnabled(id, enabled);
}
// Configure the Ralph tracker
if (completionPhrase !== undefined) {
// Start loop with completion phrase to set it up for watching
if (completionPhrase) {
session.ralphTracker.startLoop(completionPhrase, maxIterations || undefined);
}
}
if (maxIterations !== undefined) {
session.ralphTracker.setMaxIterations(maxIterations || null);
}
if (maxTodos !== undefined) {
session.ralphTracker.setMaxTodos(maxTodos);
}
if (todoExpirationMinutes !== undefined) {
session.ralphTracker.setTodoExpirationMinutes(todoExpirationMinutes);
}
// Persist and broadcast the update
ctx.persistSessionState(session);
ctx.broadcast(SseEvent.SessionRalphLoopUpdate, {
sessionId: id,
state: session.ralphLoopState,
});
return {};
});
// Reset circuit breaker for Ralph tracker
app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
session.ralphTracker.resetCircuitBreaker();
return {};
});
// Get Ralph status block and circuit breaker state
app.get('/api/sessions/:id/ralph-status', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
return {
success: true,
data: {
lastStatusBlock: session.ralphTracker.lastStatusBlock,
circuitBreaker: session.ralphTracker.circuitBreakerStatus,
cumulativeStats: session.ralphTracker.cumulativeStats,
exitGateMet: session.ralphTracker.exitGateMet,
},
};
});
// ═══════════════════════════════════════════════════════════════
// Fix Plan CRUD (@fix_plan.md generation, import, read/write)
// ═══════════════════════════════════════════════════════════════
// Generate @fix_plan.md content from todos
app.get('/api/sessions/:id/fix-plan', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const content = session.ralphTracker.generateFixPlanMarkdown();
return {
success: true,
data: {
content,
todoCount: session.ralphTracker.todos.length,
},
};
});
// Import todos from @fix_plan.md content
app.post('/api/sessions/:id/fix-plan/import', async (req) => {
const { id } = req.params as { id: string };
const { content } = parseBody(FixPlanImportSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id, req);
const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
ctx.persistSessionState(session);
return {
success: true,
data: {
importedCount,
todos: session.ralphTracker.todos,
},
};
});
// Write @fix_plan.md to session's working directory
app.post('/api/sessions/:id/fix-plan/write', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const workingDir = session.workingDir;
if (!workingDir) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory');
}
const content = session.ralphTracker.generateFixPlanMarkdown();
const filePath = join(workingDir, '@fix_plan.md');
try {
await fs.writeFile(filePath, content, 'utf-8');
return {
success: true,
data: {
filePath,
todoCount: session.ralphTracker.todos.length,
},
};
} catch (error) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to write file: ${error}`);
}
});
// Read @fix_plan.md from session's working directory and import
app.post('/api/sessions/:id/fix-plan/read', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const workingDir = session.workingDir;
if (!workingDir) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory');
}
const filePath = join(workingDir, '@fix_plan.md');
try {
const content = await fs.readFile(filePath, 'utf-8');
const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
ctx.persistSessionState(session);
return {
success: true,
data: {
filePath,
importedCount,
todos: session.ralphTracker.todos,
},
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return createErrorResponse(ApiErrorCode.NOT_FOUND, '@fix_plan.md not found in working directory');
}
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${error}`);
}
});
// ═══════════════════════════════════════════════════════════════
// Ralph Prompt & Loop (prompt write, loop start)
// ═══════════════════════════════════════════════════════════════
// Write Ralph prompt to file in session's working directory
// This avoids mux input escaping issues with long multi-line prompts
app.post('/api/sessions/:id/ralph-prompt/write', async (req) => {
const { id } = req.params as { id: string };
const { content } = parseBody(RalphPromptWriteSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id, req);
const workingDir = session.workingDir;
if (!workingDir) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory');
}
const filePath = join(workingDir, '@ralph_prompt.md');
try {
await fs.writeFile(filePath, content, 'utf-8');
return {
success: true,
data: {
filePath,
contentLength: content.length,
},
};
} catch (error) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to write file: ${error}`);
}
});
// Start a Ralph Loop — creates a new session with autonomous cycling
app.post('/api/ralph-loop/start', async (req): Promise<ApiResponse> => {
const rlOwner = ownerFor(req);
const capMsg = sessionCapacityMessage(ctx.sessions, rlOwner);
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
const {
caseName,
taskDescription,
completionPhrase,
maxIterations,
enableRespawn,
planItems,
envOverrides,
effort,
} = parseBody(RalphLoopStartSchema, req.body);
// Multi-user: cases live in the requesting user's space.
const rlCasesBase = resolveCasesDir(getAuthUser(req));
const casePath = join(rlCasesBase, caseName);
// Security: Path traversal protection
const rlResolvedPath = resolve(casePath);
const rlResolvedBase = resolve(rlCasesBase);
const rlRelPath = relative(rlResolvedBase, rlResolvedPath);
if (rlRelPath.startsWith('..') || isAbsolute(rlRelPath)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
}
// Create case folder if it doesn't exist (reuse quick-start logic)
if (!existsSync(casePath)) {
try {
mkdirSync(casePath, { recursive: true });
mkdirSync(join(casePath, 'src'), { recursive: true });
const templatePath = await ctx.getDefaultClaudeMdPath();
const claudeMd = generateClaudeMd(caseName, '', templatePath);
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
await writeHooksConfig(casePath);
ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: casePath });
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
}
}
// Strip stale disk entries for keys this request is actively setting.
if (envOverrides && Object.keys(envOverrides).length > 0) {
await stripCaseEnvKeys(casePath, Object.keys(envOverrides));
}
// Create session
const niceConfig = await ctx.getGlobalNiceConfig();
const rlModelConfig = await ctx.getModelConfig();
const rlClaudeModeConfig = await ctx.getClaudeModeConfig();
const rlClaudeMode = await resolveClaudeModeForUsername(rlClaudeModeConfig.claudeMode, rlOwner);
const session = new Session({
workingDir: casePath,
mux: ctx.mux,
useMux: true,
mode: 'claude',
niceConfig,
model: rlModelConfig?.defaultModel || undefined,
claudeMode: rlClaudeMode,
allowedTools: rlClaudeModeConfig.allowedTools,
envOverrides,
effort,
owner: rlOwner,
});
// Configure Ralph tracker
autoConfigureRalph(session, casePath, ctx);
if (!session.ralphTracker.enabled) {
session.ralphTracker.enable();
session.ralphTracker.enableAutoEnable();
}
session.ralphTracker.startLoop(completionPhrase, maxIterations ?? undefined);
// Build fix_plan markdown from plan items if provided
const enabledItems = planItems?.filter((i) => i.enabled) ?? [];
let planContent = '';
if (enabledItems.length > 0) {
const p0 = enabledItems.filter((i) => i.priority === 'P0');
const p1 = enabledItems.filter((i) => i.priority === 'P1');
const p2 = enabledItems.filter((i) => i.priority === 'P2');
const noPri = enabledItems.filter((i) => !i.priority);
planContent = '# Implementation Plan\n\n';
planContent += `Generated: ${new Date().toISOString().slice(0, 10)}\n\n`;
if (p0.length > 0) {
planContent += '## Critical Path (P0)\n\n';
p0.forEach((i) => {
planContent += `- [ ] ${i.content}\n`;
});
planContent += '\n';
}
if (p1.length > 0) {
planContent += '## Standard (P1)\n\n';
p1.forEach((i) => {
planContent += `- [ ] ${i.content}\n`;
});
planContent += '\n';
}
if (p2.length > 0) {
planContent += '## Nice-to-Have (P2)\n\n';
p2.forEach((i) => {
planContent += `- [ ] ${i.content}\n`;
});
planContent += '\n';
}
if (noPri.length > 0) {
planContent += '## Tasks\n\n';
noPri.forEach((i) => {
planContent += `- [ ] ${i.content}\n`;
});
planContent += '\n';
}
// Import into tracker and write to disk
session.ralphTracker.importFixPlanMarkdown(planContent);
const fixPlanPath = join(casePath, '@fix_plan.md');
writeFileSync(fixPlanPath, planContent, 'utf-8');
}
// Build full prompt (includes the RALPH_STATUS contract)
const fullPrompt = buildRalphLoopPrompt({
taskDescription,
completionPhrase,
hasPlan: enabledItems.length > 0,
});
// Write prompt to file
const promptPath = join(casePath, '@ralph_prompt.md');
writeFileSync(promptPath, fullPrompt, 'utf-8');
// Register session
await ctx.addSession(session);
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
getLifecycleLog().log({
event: 'created',
sessionId: session.id,
name: session.name,
reason: 'ralph_loop_start',
});
ctx.broadcast(SseEvent.SessionCreated, ctx.getSessionStateWithRespawn(session));
// Start interactive mode
try {
await session.startInteractive();
getLifecycleLog().log({
event: 'started',
sessionId: session.id,
name: session.name,
mode: 'claude',
});
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode: 'claude' });
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
} catch (err) {
await ctx.cleanupSession(session.id, true, 'ralph_loop_start_error');
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
// Enable respawn if requested
if (enableRespawn) {
const ralphUpdatePrompt =
'Before /clear: Update CLAUDE.md with discoveries and notes, mark completed tasks in @fix_plan.md, write a brief progress summary to a file so the next iteration can continue seamlessly.';
const ralphKickstartPrompt = `You are in a Ralph Wiggum loop. Read @fix_plan.md for task status, continue on the next uncompleted task, output <promise>${completionPhrase}</promise> when ALL tasks are complete.`;
const controller = new RespawnController(session, {
updatePrompt: ralphUpdatePrompt,
sendClear: true,
sendInit: true,
kickstartPrompt: ralphKickstartPrompt,
});
ctx.respawnControllers.set(session.id, controller);
ctx.setupRespawnListeners(session.id, controller);
controller.start();
ctx.saveRespawnConfig(session.id, controller.getConfig());
ctx.persistSessionState(session);
ctx.broadcast(SseEvent.RespawnStarted, {
sessionId: session.id,
status: controller.getStatus(),
});
}
// Save lastUsedCase
try {
let settings: Record<string, unknown> = {};
try {
settings = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
} catch {
/* ignore */
}
settings.lastUsedCase = caseName;
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); // Ignore - persisting lastUsedCase is non-critical
} catch {
/* non-critical */
}
const sessionId = session.id;
// Async: poll for CLI readiness, then send prompt
setImmediate(() => {
const pollReady = async () => {
for (let attempt = 0; attempt < 60; attempt++) {
await new Promise((r) => setTimeout(r, 500));
const s = ctx.sessions.get(sessionId);
if (!s) return; // session was deleted
// Check terminal output for prompt indicator
const termBuf = s.getTerminalBuffer().slice(-2048);
if (termBuf.includes('\u276F') || termBuf.includes('tokens')) {
break;
}
}
// Small extra delay for CLI to settle
await new Promise((r) => setTimeout(r, 2000));
const s = ctx.sessions.get(sessionId);
if (!s) return;
try {
await s.writeViaMux('Read @ralph_prompt.md and follow the instructions. Start working immediately.\r');
} catch (err) {
console.warn(`[RalphLoop] Failed to send prompt to session ${sessionId}:`, getErrorMessage(err));
}
};
pollReady().catch((err) => console.error('[RalphLoop] pollReady error:', err));
});
return {
success: true,
data: { sessionId, caseName },
};
});
}