mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-12 02:37:45 +08:00
feat: add CCW Loop System for automated iterative workflow execution
Implements a complete loop execution system with multi-loop parallel support, dashboard monitoring, and comprehensive security validation. Core features: - Loop orchestration engine (loop-manager, loop-state-manager) - Multi-loop parallel execution with independent state management - REST API endpoints for loop control (pause, resume, stop, retry) - WebSocket real-time status updates - Dashboard Loop Monitor view with live updates - Security: path traversal protection and sandboxed JavaScript evaluation Test coverage: - 42 comprehensive tests covering multi-loop, API, WebSocket, security - Security validation for success_condition injection attacks - Edge case handling and end-to-end workflow tests
This commit is contained in:
386
ccw/src/core/routes/loop-routes.ts
Normal file
386
ccw/src/core/routes/loop-routes.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* Loop Routes Module
|
||||
* CCW Loop System - HTTP API endpoints for Dashboard
|
||||
* Reference: .workflow/.scratchpad/loop-system-complete-design-20260121.md section 6.1
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET /api/loops - List all loops
|
||||
* - POST /api/loops - Start new loop from task
|
||||
* - GET /api/loops/stats - Get loop statistics
|
||||
* - GET /api/loops/:loopId - Get specific loop details
|
||||
* - GET /api/loops/:loopId/logs - Get loop execution logs
|
||||
* - GET /api/loops/:loopId/history - Get execution history (paginated)
|
||||
* - POST /api/loops/:loopId/pause - Pause loop
|
||||
* - POST /api/loops/:loopId/resume - Resume loop
|
||||
* - POST /api/loops/:loopId/stop - Stop loop
|
||||
* - POST /api/loops/:loopId/retry - Retry failed step
|
||||
*/
|
||||
|
||||
import { join } from 'path';
|
||||
import { LoopManager } from '../../tools/loop-manager.js';
|
||||
import type { RouteContext } from './types.js';
|
||||
import type { LoopState } from '../../types/loop.js';
|
||||
|
||||
/**
|
||||
* Handle loop routes
|
||||
* @returns true if route was handled, false otherwise
|
||||
*/
|
||||
export async function handleLoopRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, req, res, initialPath, handlePostRequest, url } = ctx;
|
||||
|
||||
// Get workflow directory from initialPath
|
||||
const workflowDir = initialPath || process.cwd();
|
||||
const loopManager = new LoopManager(workflowDir);
|
||||
|
||||
// ==== EXACT PATH ROUTES (must come first) ====
|
||||
|
||||
// GET /api/loops/stats - Get loop statistics
|
||||
if (pathname === '/api/loops/stats' && req.method === 'GET') {
|
||||
try {
|
||||
const loops = await loopManager.listLoops();
|
||||
const stats = computeLoopStats(loops);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, data: stats, timestamp: new Date().toISOString() }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/loops - Start new loop from task
|
||||
if (pathname === '/api/loops' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { taskId } = body as { taskId?: string };
|
||||
|
||||
if (!taskId) {
|
||||
return { success: false, error: 'taskId is required', status: 400 };
|
||||
}
|
||||
|
||||
try {
|
||||
// Read task config from .task directory
|
||||
const taskPath = join(workflowDir, '.task', taskId + '.json');
|
||||
const { readFile } = await import('fs/promises');
|
||||
const { existsSync } = await import('fs');
|
||||
|
||||
if (!existsSync(taskPath)) {
|
||||
return { success: false, error: 'Task not found: ' + taskId, status: 404 };
|
||||
}
|
||||
|
||||
const taskContent = await readFile(taskPath, 'utf-8');
|
||||
const task = JSON.parse(taskContent);
|
||||
|
||||
if (!task.loop_control?.enabled) {
|
||||
return { success: false, error: 'Task ' + taskId + ' does not have loop enabled', status: 400 };
|
||||
}
|
||||
|
||||
const loopId = await loopManager.startLoop(task);
|
||||
|
||||
return { success: true, data: { loopId, taskId } };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message, status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/loops - List all loops
|
||||
if (pathname === '/api/loops' && req.method === 'GET') {
|
||||
try {
|
||||
const loops = await loopManager.listLoops();
|
||||
|
||||
// Parse query params for filtering
|
||||
const searchParams = url?.searchParams;
|
||||
let filteredLoops = loops;
|
||||
|
||||
// Filter by status
|
||||
const statusFilter = searchParams?.get('status');
|
||||
if (statusFilter && statusFilter !== 'all') {
|
||||
filteredLoops = filteredLoops.filter(l => l.status === statusFilter);
|
||||
}
|
||||
|
||||
// Sort by updated_at (most recent first)
|
||||
filteredLoops.sort((a, b) =>
|
||||
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
|
||||
);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
data: filteredLoops,
|
||||
total: filteredLoops.length,
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
return true;
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ==== NESTED PATH ROUTES (more specific patterns first) ====
|
||||
|
||||
// GET /api/loops/:loopId/logs - Get loop execution logs
|
||||
if (pathname.match(/\/api\/loops\/[^/]+\/logs$/) && req.method === 'GET') {
|
||||
const loopId = pathname.split('/').slice(-2)[0];
|
||||
if (!loopId || !isValidId(loopId)) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const state = await loopManager.getStatus(loopId);
|
||||
|
||||
// Extract logs from state_variables
|
||||
const logs: Array<{
|
||||
step_id: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
timestamp?: string;
|
||||
}> = [];
|
||||
|
||||
// Group by step_id
|
||||
const stepIds = new Set<string>();
|
||||
for (const key of Object.keys(state.state_variables || {})) {
|
||||
const match = key.match(/^(.+)_(stdout|stderr)$/);
|
||||
if (match) stepIds.add(match[1]);
|
||||
}
|
||||
|
||||
for (const stepId of stepIds) {
|
||||
logs.push({
|
||||
step_id: stepId,
|
||||
stdout: state.state_variables?.[`${stepId}_stdout`] || '',
|
||||
stderr: state.state_variables?.[`${stepId}_stderr`] || ''
|
||||
});
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
loop_id: loopId,
|
||||
logs,
|
||||
total: logs.length
|
||||
}
|
||||
}));
|
||||
return true;
|
||||
} catch (error) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/loops/:loopId/history - Get execution history (paginated)
|
||||
if (pathname.match(/\/api\/loops\/[^/]+\/history$/) && req.method === 'GET') {
|
||||
const loopId = pathname.split('/').slice(-2)[0];
|
||||
if (!loopId || !isValidId(loopId)) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const state = await loopManager.getStatus(loopId);
|
||||
const history = state.execution_history || [];
|
||||
|
||||
// Parse pagination params
|
||||
const searchParams = url?.searchParams;
|
||||
const limit = parseInt(searchParams?.get('limit') || '50', 10);
|
||||
const offset = parseInt(searchParams?.get('offset') || '0', 10);
|
||||
|
||||
// Slice history for pagination
|
||||
const paginatedHistory = history.slice(offset, offset + limit);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
data: paginatedHistory,
|
||||
total: history.length,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: offset + limit < history.length
|
||||
}));
|
||||
return true;
|
||||
} catch (error) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/loops/:loopId/pause - Pause loop
|
||||
if (pathname.match(/\/api\/loops\/[^/]+\/pause$/) && req.method === 'POST') {
|
||||
const loopId = pathname.split('/').slice(-2)[0];
|
||||
if (!loopId || !isValidId(loopId)) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
await loopManager.pauseLoop(loopId);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, message: 'Loop paused' }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/loops/:loopId/resume - Resume loop
|
||||
if (pathname.match(/\/api\/loops\/[^/]+\/resume$/) && req.method === 'POST') {
|
||||
const loopId = pathname.split('/').slice(-2)[0];
|
||||
if (!loopId || !isValidId(loopId)) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
await loopManager.resumeLoop(loopId);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, message: 'Loop resumed' }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/loops/:loopId/stop - Stop loop
|
||||
if (pathname.match(/\/api\/loops\/[^/]+\/stop$/) && req.method === 'POST') {
|
||||
const loopId = pathname.split('/').slice(-2)[0];
|
||||
if (!loopId || !isValidId(loopId)) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
await loopManager.stopLoop(loopId);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, message: 'Loop stopped' }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/loops/:loopId/retry - Retry failed step
|
||||
if (pathname.match(/\/api\/loops\/[^/]+\/retry$/) && req.method === 'POST') {
|
||||
const loopId = pathname.split('/').slice(-2)[0];
|
||||
if (!loopId || !isValidId(loopId)) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const state = await loopManager.getStatus(loopId);
|
||||
|
||||
// Can only retry if paused or failed
|
||||
if (!['paused', 'failed'].includes(state.status)) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Can only retry paused or failed loops'
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Resume the loop (retry from current step)
|
||||
await loopManager.resumeLoop(loopId);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, message: 'Loop retry initiated' }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ==== SINGLE PARAM ROUTES (most generic, must come last) ====
|
||||
|
||||
// GET /api/loops/:loopId - Get specific loop details
|
||||
if (pathname.match(/^\/api\/loops\/[^/]+$/) && req.method === 'GET') {
|
||||
const loopId = pathname.split('/').pop();
|
||||
if (!loopId || !isValidId(loopId)) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const state = await loopManager.getStatus(loopId);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, data: state }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute statistics from loop list
|
||||
*/
|
||||
function computeLoopStats(loops: LoopState[]): {
|
||||
total: number;
|
||||
by_status: Record<string, number>;
|
||||
active_count: number;
|
||||
success_rate: number;
|
||||
avg_iterations: number;
|
||||
} {
|
||||
const byStatus: Record<string, number> = {};
|
||||
|
||||
for (const loop of loops) {
|
||||
byStatus[loop.status] = (byStatus[loop.status] || 0) + 1;
|
||||
}
|
||||
|
||||
const completedCount = byStatus['completed'] || 0;
|
||||
const failedCount = byStatus['failed'] || 0;
|
||||
const totalFinished = completedCount + failedCount;
|
||||
|
||||
const successRate = totalFinished > 0
|
||||
? Math.round((completedCount / totalFinished) * 100)
|
||||
: 0;
|
||||
|
||||
const avgIterations = loops.length > 0
|
||||
? Math.round(loops.reduce((sum, l) => sum + l.current_iteration, 0) / loops.length * 10) / 10
|
||||
: 0;
|
||||
|
||||
return {
|
||||
total: loops.length,
|
||||
by_status: byStatus,
|
||||
active_count: (byStatus['running'] || 0) + (byStatus['paused'] || 0),
|
||||
success_rate: successRate,
|
||||
avg_iterations: avgIterations
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize ID parameter to prevent path traversal attacks
|
||||
* @returns true if valid, false if invalid
|
||||
*/
|
||||
function isValidId(id: string): boolean {
|
||||
if (!id) return false;
|
||||
// Block path traversal attempts and null bytes
|
||||
if (id.includes('/') || id.includes('\\') || id === '..' || id === '.') return false;
|
||||
if (id.includes('\0')) return false;
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user