mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 07:29:42 +02:00
468 lines
19 KiB
TypeScript
468 lines
19 KiB
TypeScript
/**
|
|
* @fileoverview Plan generation and management routes.
|
|
* Covers AI-powered plan generation (simple + detailed orchestration),
|
|
* plan task CRUD, checkpoints, version history, and rollback.
|
|
*/
|
|
|
|
import { FastifyInstance } from 'fastify';
|
|
import { join } from 'node:path';
|
|
import { existsSync, rmSync } from 'node:fs';
|
|
import { Session } from '../../session.js';
|
|
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
|
import { PlanOrchestrator, type PlanItem, type DetailedPlanResult } from '../../plan-orchestrator.js';
|
|
import {
|
|
GeneratePlanSchema,
|
|
GeneratePlanDetailedSchema,
|
|
CancelPlanSchema,
|
|
PlanTaskUpdateSchema,
|
|
PlanTaskAddSchema,
|
|
} from '../schemas.js';
|
|
import { findSessionOrFail, CASES_DIR, validatePathWithinBase } from '../route-helpers.js';
|
|
import { SseEvent } from '../sse-events.js';
|
|
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js';
|
|
|
|
export function registerPlanRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort & InfraPort): void {
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Plan Generation (simple AI + detailed orchestration)
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
// ========== Generate Plan (Simple) ==========
|
|
|
|
app.post('/api/generate-plan', async (req): Promise<ApiResponse> => {
|
|
const gpResult = GeneratePlanSchema.safeParse(req.body);
|
|
if (!gpResult.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
}
|
|
const { taskDescription, detailLevel = 'standard' } = gpResult.data;
|
|
|
|
// Build sophisticated prompt based on Ralph Wiggum methodology
|
|
const detailConfig = {
|
|
brief: { style: 'high-level milestones', testDepth: 'basic' },
|
|
standard: { style: 'balanced implementation steps', testDepth: 'thorough' },
|
|
detailed: {
|
|
style: 'granular sub-tasks with full TDD coverage',
|
|
testDepth: 'comprehensive',
|
|
},
|
|
};
|
|
const levelConfig = detailConfig[detailLevel] || detailConfig.standard;
|
|
|
|
const prompt = `You are an expert software architect breaking down a task into a thorough implementation plan.
|
|
|
|
## TASK TO IMPLEMENT
|
|
${taskDescription}
|
|
|
|
## YOUR MISSION
|
|
Create a detailed, actionable implementation plan following Test-Driven Development (TDD) methodology.
|
|
Think deeply about:
|
|
- What are ALL the components, modules, and features needed?
|
|
- What could go wrong? Add defensive steps for error handling.
|
|
- How will we verify each part works? Tests before implementation.
|
|
- What edge cases need handling?
|
|
- What's the logical order of dependencies?
|
|
|
|
## DETAIL LEVEL: ${detailLevel.toUpperCase()}
|
|
Style: ${levelConfig.style}
|
|
Generate as many steps as needed to properly cover the task - don't artificially limit yourself.
|
|
For complex projects, this could be 30, 50, or even 100+ steps. Quality over brevity.
|
|
|
|
## PLAN STRUCTURE
|
|
|
|
Your plan MUST include these phases in order:
|
|
|
|
### Phase 1: Foundation & Setup
|
|
- Project structure, dependencies, configuration
|
|
- Database schemas, type definitions, interfaces
|
|
|
|
### Phase 2: Core Implementation (TDD Cycle)
|
|
For EACH feature:
|
|
1. Write failing tests first (unit tests)
|
|
2. Implement the feature
|
|
3. Run tests, debug until passing
|
|
4. Refactor if needed
|
|
|
|
### Phase 3: Integration & Edge Cases
|
|
- Integration tests for feature interactions
|
|
- Edge case handling (errors, boundaries, invalid input)
|
|
- Error messages and user feedback
|
|
|
|
### Phase 4: Verification & Hardening
|
|
- Run full test suite
|
|
- Fix any failing tests
|
|
- Add missing test coverage
|
|
- Final verification that ALL requirements are met
|
|
|
|
## OUTPUT FORMAT
|
|
Return ONLY a JSON array. Each item MUST have:
|
|
- id: unique identifier (e.g., "P0-001", "P1-002")
|
|
- content: specific action (verb phrase, 15-120 chars, be descriptive!)
|
|
- priority: "P0" (critical/blocking), "P1" (required), "P2" (enhancement)
|
|
- verificationCriteria: HOW to verify this step is complete (required!)
|
|
- tddPhase: "setup" | "test" | "impl" | "verify"
|
|
- dependencies: array of task IDs this depends on (empty if none)
|
|
|
|
## EXAMPLE OUTPUT
|
|
[
|
|
{"id": "P0-001", "content": "Create project structure with src/, tests/, and config directories", "priority": "P0", "verificationCriteria": "Directories exist, package.json initialized", "tddPhase": "setup", "dependencies": []},
|
|
{"id": "P0-002", "content": "Define TypeScript interfaces for User, Session, and AuthToken types", "priority": "P0", "verificationCriteria": "Types compile without errors, exported from types.ts", "tddPhase": "setup", "dependencies": ["P0-001"]},
|
|
{"id": "P0-003", "content": "Write failing unit tests for password hashing (valid password, empty, too short)", "priority": "P0", "verificationCriteria": "Tests exist, fail with 'not implemented'", "tddPhase": "test", "dependencies": ["P0-002"]},
|
|
{"id": "P0-004", "content": "Implement password hashing with bcrypt, configurable salt rounds", "priority": "P0", "verificationCriteria": "npm test -- --grep='password' passes", "tddPhase": "impl", "dependencies": ["P0-003"]},
|
|
{"id": "P0-005", "content": "Write failing tests for JWT token generation and validation", "priority": "P0", "verificationCriteria": "Tests exist, fail with 'not implemented'", "tddPhase": "test", "dependencies": ["P0-004"]},
|
|
{"id": "P0-006", "content": "Implement JWT service with access/refresh token support", "priority": "P0", "verificationCriteria": "npm test -- --grep='JWT' passes", "tddPhase": "impl", "dependencies": ["P0-005"]},
|
|
{"id": "P1-001", "content": "Write integration tests for login flow (valid creds, invalid, locked account)", "priority": "P1", "verificationCriteria": "Integration tests exist, fail until endpoint implemented", "tddPhase": "test", "dependencies": ["P0-006"]},
|
|
{"id": "P1-002", "content": "Implement login endpoint with rate limiting and audit logging", "priority": "P1", "verificationCriteria": "All login tests pass, endpoint returns 200/401 correctly", "tddPhase": "impl", "dependencies": ["P1-001"]},
|
|
{"id": "P1-003", "content": "Run full test suite and verify all tests pass", "priority": "P1", "verificationCriteria": "npm test exits with code 0, coverage > 80%", "tddPhase": "verify", "dependencies": ["P1-002"]}
|
|
]
|
|
|
|
## CRITICAL RULES
|
|
1. EVERY task MUST have verificationCriteria - this is non-negotiable!
|
|
2. EVERY implementation step should have a corresponding test step BEFORE it
|
|
3. Use tddPhase: "test" for writing tests, "impl" for implementation
|
|
4. Dependencies must form a valid DAG - no cycles
|
|
5. Be SPECIFIC - not "Add tests" but "Write tests for X covering Y and Z"
|
|
6. End with verification that ALL original requirements are met
|
|
7. Use P0 for foundation and core features, P1 for required work, P2 for nice-to-have
|
|
|
|
NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|
|
|
// Create temporary session for the AI call using Opus 4.5 for deep reasoning
|
|
const session = new Session({
|
|
workingDir: process.cwd(),
|
|
mux: ctx.mux,
|
|
useMux: false, // No mux needed for one-shot
|
|
mode: 'claude',
|
|
});
|
|
|
|
// Use configured model for plan generation, falling back to opus
|
|
const planModelConfig = await ctx.getModelConfig();
|
|
const modelToUse = planModelConfig?.agentTypeOverrides?.implement || planModelConfig?.defaultModel || 'opus';
|
|
|
|
try {
|
|
const { result, cost } = await session.runPrompt(prompt, { model: modelToUse });
|
|
|
|
// Parse JSON from result
|
|
const jsonMatch = result.match(/\[[\s\S]*\]/);
|
|
if (!jsonMatch) {
|
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to parse plan - no JSON array found');
|
|
}
|
|
|
|
let items: PlanItem[];
|
|
try {
|
|
const parsed = JSON.parse(jsonMatch[0]);
|
|
if (!Array.isArray(parsed)) {
|
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Invalid response - expected array');
|
|
}
|
|
|
|
// Validate and normalize items with enhanced fields
|
|
items = parsed.map((item: unknown, idx: number) => {
|
|
if (typeof item !== 'object' || item === null) {
|
|
return {
|
|
id: `task-${idx}`,
|
|
content: `Step ${idx + 1}`,
|
|
priority: null,
|
|
verificationCriteria: 'Task completed successfully',
|
|
status: 'pending' as const,
|
|
attempts: 0,
|
|
version: 1,
|
|
};
|
|
}
|
|
const obj = item as Record<string, unknown>;
|
|
const content = typeof obj.content === 'string' ? obj.content.slice(0, 200) : `Step ${idx + 1}`;
|
|
let priority: 'P0' | 'P1' | 'P2' | null = null;
|
|
if (obj.priority === 'P0' || obj.priority === 'P1' || obj.priority === 'P2') {
|
|
priority = obj.priority;
|
|
}
|
|
|
|
// Parse tddPhase
|
|
let tddPhase: 'setup' | 'test' | 'impl' | 'verify' | undefined;
|
|
if (
|
|
obj.tddPhase === 'setup' ||
|
|
obj.tddPhase === 'test' ||
|
|
obj.tddPhase === 'impl' ||
|
|
obj.tddPhase === 'verify'
|
|
) {
|
|
tddPhase = obj.tddPhase;
|
|
}
|
|
|
|
return {
|
|
id: obj.id ? String(obj.id) : `task-${idx}`,
|
|
content,
|
|
priority,
|
|
verificationCriteria:
|
|
typeof obj.verificationCriteria === 'string' ? obj.verificationCriteria : 'Task completed successfully',
|
|
tddPhase,
|
|
dependencies: Array.isArray(obj.dependencies) ? obj.dependencies.map(String) : [],
|
|
status: 'pending' as const,
|
|
attempts: 0,
|
|
version: 1,
|
|
};
|
|
});
|
|
// No artificial limit - let Claude generate what's needed
|
|
} catch (parseErr) {
|
|
return createErrorResponse(
|
|
ApiErrorCode.OPERATION_FAILED,
|
|
'Failed to parse plan JSON: ' + getErrorMessage(parseErr)
|
|
);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: { items, costUsd: cost },
|
|
};
|
|
} catch (err) {
|
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Plan generation failed: ' + getErrorMessage(err));
|
|
} finally {
|
|
// Clean up the temporary session
|
|
try {
|
|
await session.stop();
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
}
|
|
});
|
|
|
|
// ========== Generate Plan (Detailed Orchestration) ==========
|
|
|
|
app.post('/api/generate-plan-detailed', async (req): Promise<ApiResponse> => {
|
|
const gpdResult = GeneratePlanDetailedSchema.safeParse(req.body);
|
|
if (!gpdResult.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
}
|
|
const { taskDescription, caseName } = gpdResult.data;
|
|
|
|
// Determine output directory for saving wizard results
|
|
let outputDir: string | undefined;
|
|
if (caseName) {
|
|
const casePath = validatePathWithinBase(caseName, CASES_DIR);
|
|
if (casePath && existsSync(casePath)) {
|
|
outputDir = join(casePath, 'ralph-wizard');
|
|
|
|
// Clear old ralph-wizard directory to ensure fresh prompts for each generation
|
|
// This prevents stale prompts from previous runs being shown when clicking on agents
|
|
if (existsSync(outputDir)) {
|
|
try {
|
|
rmSync(outputDir, { recursive: true, force: true });
|
|
console.log(`[API] Cleared old ralph-wizard directory: ${outputDir}`);
|
|
} catch (err) {
|
|
console.warn(`[API] Failed to clear ralph-wizard directory:`, err);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const detailedModelConfig = await ctx.getModelConfig();
|
|
const orchestrator = new PlanOrchestrator(ctx.mux, process.cwd(), outputDir, detailedModelConfig ?? undefined);
|
|
|
|
// Store orchestrator for potential cancellation via API (not on disconnect)
|
|
// Plan generation continues even if browser disconnects - only explicit cancel stops it
|
|
const orchestratorId = `plan-${Date.now()}`;
|
|
ctx.activePlanOrchestrators.set(orchestratorId, orchestrator);
|
|
|
|
// Broadcast the orchestrator ID so frontend can cancel if needed
|
|
ctx.broadcast(SseEvent.PlanStarted, { orchestratorId });
|
|
|
|
// Track progress for SSE updates
|
|
const progressUpdates: Array<{ phase: string; detail: string; timestamp: number }> = [];
|
|
const onProgress = (phase: string, detail: string) => {
|
|
const update = { phase, detail, timestamp: Date.now() };
|
|
progressUpdates.push(update);
|
|
// Broadcast progress to connected clients
|
|
ctx.broadcast(SseEvent.PlanProgress, update);
|
|
};
|
|
|
|
// Broadcast plan subagent events for UI visibility
|
|
const onSubagent = (event: {
|
|
type: string;
|
|
agentId: string;
|
|
agentType: string;
|
|
model: string;
|
|
status: string;
|
|
detail?: string;
|
|
itemCount?: number;
|
|
durationMs?: number;
|
|
error?: string;
|
|
}) => {
|
|
ctx.broadcast(SseEvent.PlanSubagent, event);
|
|
};
|
|
|
|
try {
|
|
const result: DetailedPlanResult = await orchestrator.generateDetailedPlan(
|
|
taskDescription,
|
|
onProgress,
|
|
onSubagent
|
|
);
|
|
|
|
// Clean up orchestrator from active map
|
|
ctx.activePlanOrchestrators.delete(orchestratorId);
|
|
ctx.broadcast(SseEvent.PlanCompleted, { orchestratorId, success: result.success });
|
|
|
|
if (!result.success) {
|
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, result.error || 'Plan generation failed');
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
items: result.items,
|
|
costUsd: result.costUsd,
|
|
metadata: result.metadata,
|
|
progressLog: progressUpdates,
|
|
orchestratorId,
|
|
},
|
|
};
|
|
} catch (err) {
|
|
// Clean up on error too
|
|
ctx.activePlanOrchestrators.delete(orchestratorId);
|
|
ctx.broadcast(SseEvent.PlanCompleted, {
|
|
orchestratorId,
|
|
success: false,
|
|
error: getErrorMessage(err),
|
|
});
|
|
return createErrorResponse(
|
|
ApiErrorCode.OPERATION_FAILED,
|
|
'Detailed plan generation failed: ' + getErrorMessage(err)
|
|
);
|
|
}
|
|
});
|
|
|
|
// ========== Cancel Plan Generation ==========
|
|
|
|
app.post('/api/cancel-plan-generation', async (req): Promise<ApiResponse> => {
|
|
const cpResult = CancelPlanSchema.safeParse(req.body);
|
|
if (!cpResult.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
}
|
|
const { orchestratorId } = cpResult.data;
|
|
|
|
// If specific orchestrator ID provided, cancel just that one
|
|
if (orchestratorId) {
|
|
const orchestrator = ctx.activePlanOrchestrators.get(orchestratorId);
|
|
if (!orchestrator) {
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Plan generation not found or already completed');
|
|
}
|
|
console.log(`[API] Cancelling plan generation ${orchestratorId}`);
|
|
await orchestrator.cancel();
|
|
ctx.activePlanOrchestrators.delete(orchestratorId);
|
|
ctx.broadcast(SseEvent.PlanCancelled, { orchestratorId });
|
|
return { success: true, data: { cancelled: orchestratorId } };
|
|
}
|
|
|
|
// Otherwise cancel all active plan generations
|
|
const cancelled: string[] = [];
|
|
for (const [id, orchestrator] of ctx.activePlanOrchestrators) {
|
|
console.log(`[API] Cancelling plan generation ${id}`);
|
|
await orchestrator.cancel();
|
|
cancelled.push(id);
|
|
ctx.broadcast(SseEvent.PlanCancelled, { orchestratorId: id });
|
|
}
|
|
ctx.activePlanOrchestrators.clear();
|
|
|
|
return { success: true, data: { cancelled } };
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Plan Management (task CRUD, checkpoints, version history, rollback)
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
// ========== Update Plan Task ==========
|
|
|
|
app.patch('/api/sessions/:id/plan/task/:taskId', async (req) => {
|
|
const { id, taskId } = req.params as { id: string; taskId: string };
|
|
const session = findSessionOrFail(ctx, id);
|
|
|
|
const tracker = session.ralphTracker;
|
|
if (!tracker) {
|
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
|
|
}
|
|
|
|
const ptuResult = PlanTaskUpdateSchema.safeParse(req.body);
|
|
if (!ptuResult.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
}
|
|
const update = ptuResult.data as {
|
|
status?: 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked';
|
|
error?: string;
|
|
incrementAttempts?: boolean;
|
|
};
|
|
|
|
const result = tracker.updatePlanTask(taskId, update);
|
|
if (!result.success) {
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Task not found');
|
|
}
|
|
|
|
ctx.broadcast(SseEvent.SessionPlanTaskUpdate, { sessionId: id, taskId, update: result.task });
|
|
return { success: true, data: result.task };
|
|
});
|
|
|
|
// ========== Create Checkpoint ==========
|
|
|
|
app.post('/api/sessions/:id/plan/checkpoint', async (req) => {
|
|
const { id } = req.params as { id: string };
|
|
const session = findSessionOrFail(ctx, id);
|
|
|
|
const tracker = session.ralphTracker;
|
|
if (!tracker) {
|
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
|
|
}
|
|
|
|
const checkpoint = tracker.generateCheckpointReview();
|
|
ctx.broadcast(SseEvent.SessionPlanCheckpoint, { sessionId: id, checkpoint });
|
|
return { success: true, data: checkpoint };
|
|
});
|
|
|
|
// ========== Get Version History ==========
|
|
|
|
app.get('/api/sessions/:id/plan/history', async (req) => {
|
|
const { id } = req.params as { id: string };
|
|
const session = findSessionOrFail(ctx, id);
|
|
|
|
const tracker = session.ralphTracker;
|
|
if (!tracker) {
|
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
|
|
}
|
|
|
|
return { success: true, data: tracker.getPlanHistory() };
|
|
});
|
|
|
|
// ========== Rollback to Version ==========
|
|
|
|
app.post('/api/sessions/:id/plan/rollback/:version', async (req) => {
|
|
const { id, version } = req.params as { id: string; version: string };
|
|
const session = findSessionOrFail(ctx, id);
|
|
|
|
const tracker = session.ralphTracker;
|
|
if (!tracker) {
|
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
|
|
}
|
|
|
|
const result = tracker.rollbackToVersion(parseInt(version, 10));
|
|
if (!result.success) {
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Version not found');
|
|
}
|
|
|
|
ctx.broadcast(SseEvent.SessionPlanRollback, { sessionId: id, version: parseInt(version, 10) });
|
|
return { success: true, data: result.plan };
|
|
});
|
|
|
|
// ========== Add Plan Task ==========
|
|
|
|
app.post('/api/sessions/:id/plan/task', async (req) => {
|
|
const { id } = req.params as { id: string };
|
|
const session = findSessionOrFail(ctx, id);
|
|
|
|
const tracker = session.ralphTracker;
|
|
if (!tracker) {
|
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
|
|
}
|
|
|
|
const ptaResult = PlanTaskAddSchema.safeParse(req.body);
|
|
if (!ptaResult.success) {
|
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
}
|
|
const task = ptaResult.data;
|
|
|
|
const result = tracker.addPlanTask(task);
|
|
ctx.broadcast(SseEvent.SessionPlanTaskAdded, { sessionId: id, task: result.task });
|
|
return { success: true, data: result.task };
|
|
});
|
|
}
|