mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +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:
@@ -6,6 +6,7 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, unlinkS
|
||||
import { dirname, join, relative } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import type { RouteContext } from './types.js';
|
||||
import { getDefaultTool } from '../../tools/claude-cli-tools.js';
|
||||
|
||||
interface ClaudeFile {
|
||||
id: string;
|
||||
@@ -549,7 +550,8 @@ export async function handleClaudeRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
// API: CLI Sync (analyze and update CLAUDE.md using CLI tools)
|
||||
if (pathname === '/api/memory/claude/sync' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body: any) => {
|
||||
const { level, path: modulePath, tool = 'gemini', mode = 'update', targets } = body;
|
||||
const { level, path: modulePath, tool, mode = 'update', targets } = body;
|
||||
const resolvedTool = tool || getDefaultTool(initialPath);
|
||||
|
||||
if (!level) {
|
||||
return { error: 'Missing level parameter', status: 400 };
|
||||
@@ -598,7 +600,7 @@ export async function handleClaudeRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
type: 'CLI_EXECUTION_STARTED',
|
||||
payload: {
|
||||
executionId: syncId,
|
||||
tool: tool === 'qwen' ? 'qwen' : 'gemini',
|
||||
tool: resolvedTool,
|
||||
mode: 'analysis',
|
||||
category: 'internal',
|
||||
context: 'claude-sync',
|
||||
@@ -629,7 +631,7 @@ export async function handleClaudeRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
|
||||
const startTime = Date.now();
|
||||
const result = await executeCliTool({
|
||||
tool: tool === 'qwen' ? 'qwen' : 'gemini',
|
||||
tool: resolvedTool,
|
||||
prompt: cliPrompt,
|
||||
mode: 'analysis',
|
||||
format: 'plain',
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '../../config/cli-settings-manager.js';
|
||||
import type { SaveEndpointRequest } from '../../types/cli-settings.js';
|
||||
import { validateSettings } from '../../types/cli-settings.js';
|
||||
import { syncBuiltinToolsAvailability, getBuiltinToolsSyncReport } from '../../tools/claude-cli-tools.js';
|
||||
|
||||
/**
|
||||
* Handle CLI Settings routes
|
||||
@@ -228,5 +229,51 @@ export async function handleCliSettingsRoutes(ctx: RouteContext): Promise<boolea
|
||||
return true;
|
||||
}
|
||||
|
||||
// ========== SYNC BUILTIN TOOLS AVAILABILITY ==========
|
||||
// POST /api/cli/settings/sync-tools
|
||||
if (pathname === '/api/cli/settings/sync-tools' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body: any) => {
|
||||
const { initialPath } = ctx;
|
||||
try {
|
||||
const result = await syncBuiltinToolsAvailability(initialPath);
|
||||
|
||||
// Broadcast update event
|
||||
broadcastToClients({
|
||||
type: 'CLI_TOOLS_CONFIG_UPDATED',
|
||||
payload: {
|
||||
tools: result.config,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
changes: result.changes,
|
||||
config: result.config
|
||||
}));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/cli/settings/sync-report
|
||||
if (pathname === '/api/cli/settings/sync-report' && req.method === 'GET') {
|
||||
try {
|
||||
const { initialPath } = ctx;
|
||||
const report = await getBuiltinToolsSyncReport(initialPath);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(report));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '../../../utils/uv-manager.js';
|
||||
import type { RouteContext } from '../types.js';
|
||||
import { extractJSON } from './utils.js';
|
||||
import { getDefaultTool } from '../../../tools/claude-cli-tools.js';
|
||||
|
||||
export async function handleCodexLensSemanticRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, url, req, res, initialPath, handlePostRequest } = ctx;
|
||||
@@ -66,14 +67,14 @@ export async function handleCodexLensSemanticRoutes(ctx: RouteContext): Promise<
|
||||
// API: CodexLens LLM Enhancement (run enhance command)
|
||||
if (pathname === '/api/codexlens/enhance' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { path: projectPath, tool = 'gemini', batchSize = 5, timeoutMs = 300000 } = body as {
|
||||
const { path: projectPath, tool, batchSize = 5, timeoutMs = 300000 } = body as {
|
||||
path?: unknown;
|
||||
tool?: unknown;
|
||||
batchSize?: unknown;
|
||||
timeoutMs?: unknown;
|
||||
};
|
||||
const targetPath = typeof projectPath === 'string' && projectPath.trim().length > 0 ? projectPath : initialPath;
|
||||
const resolvedTool = typeof tool === 'string' && tool.trim().length > 0 ? tool : 'gemini';
|
||||
const resolvedTool = typeof tool === 'string' && tool.trim().length > 0 ? tool : getDefaultTool(targetPath);
|
||||
const resolvedBatchSize = typeof batchSize === 'number' ? batchSize : Number(batchSize);
|
||||
const resolvedTimeoutMs = typeof timeoutMs === 'number' ? timeoutMs : Number(timeoutMs);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getEmbeddingStatus, generateEmbeddings } from '../memory-embedder-bridg
|
||||
import { checkSemanticStatus } from '../../tools/codex-lens.js';
|
||||
import { StoragePaths } from '../../config/storage-paths.js';
|
||||
import { join } from 'path';
|
||||
import { getDefaultTool } from '../../tools/claude-cli-tools.js';
|
||||
|
||||
/**
|
||||
* Route context interface
|
||||
@@ -173,12 +174,13 @@ export async function handleCoreMemoryRoutes(ctx: RouteContext): Promise<boolean
|
||||
const memoryId = pathname.replace('/api/core-memory/memories/', '').replace('/summary', '');
|
||||
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { tool = 'gemini', path: projectPath } = body;
|
||||
const { tool, path: projectPath } = body;
|
||||
const basePath = projectPath || initialPath;
|
||||
const resolvedTool = tool || getDefaultTool(basePath);
|
||||
|
||||
try {
|
||||
const store = getCoreMemoryStore(basePath);
|
||||
const summary = await store.generateSummary(memoryId, tool);
|
||||
const summary = await store.generateSummary(memoryId, resolvedTool);
|
||||
|
||||
// Broadcast update event
|
||||
broadcastToClients({
|
||||
|
||||
@@ -6,6 +6,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { validatePath as validateAllowedPath } from '../../utils/path-validator.js';
|
||||
import type { RouteContext } from './types.js';
|
||||
import { getDefaultTool } from '../../tools/claude-cli-tools.js';
|
||||
|
||||
// ========================================
|
||||
// Constants
|
||||
@@ -471,7 +472,7 @@ export async function handleFilesRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
|
||||
const {
|
||||
path: targetPath,
|
||||
tool = 'gemini',
|
||||
tool,
|
||||
strategy = 'single-layer'
|
||||
} = body as { path?: unknown; tool?: unknown; strategy?: unknown };
|
||||
|
||||
@@ -481,9 +482,10 @@ export async function handleFilesRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
|
||||
try {
|
||||
const validatedPath = await validateAllowedPath(targetPath, { mustExist: true, allowedDirectories: [initialPath] });
|
||||
const resolvedTool = typeof tool === 'string' && tool.trim().length > 0 ? tool : getDefaultTool(validatedPath);
|
||||
return await triggerUpdateClaudeMd(
|
||||
validatedPath,
|
||||
typeof tool === 'string' ? tool : 'gemini',
|
||||
resolvedTool,
|
||||
typeof strategy === 'string' ? strategy : 'single-layer'
|
||||
);
|
||||
} catch (err) {
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { homedir } from 'os';
|
||||
import { getMemoryStore } from '../memory-store.js';
|
||||
import { executeCliTool } from '../../tools/cli-executor.js';
|
||||
import { SmartContentFormatter } from '../../tools/cli-output-converter.js';
|
||||
import { getDefaultTool } from '../../tools/claude-cli-tools.js';
|
||||
|
||||
/**
|
||||
* Route context interface
|
||||
@@ -340,7 +341,7 @@ export async function handleMemoryRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
if (pathname === '/api/memory/insights/analyze' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body: any) => {
|
||||
const projectPath = body.path || initialPath;
|
||||
const tool = body.tool || 'gemini'; // gemini, qwen, codex, claude
|
||||
const tool = body.tool || getDefaultTool(projectPath);
|
||||
const prompts = body.prompts || [];
|
||||
const lang = body.lang || 'en'; // Language preference
|
||||
|
||||
|
||||
319
ccw/src/core/routes/task-routes.ts
Normal file
319
ccw/src/core/routes/task-routes.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Task Routes Module
|
||||
* CCW Loop System - HTTP API endpoints for Task management
|
||||
* Reference: .workflow/.scratchpad/loop-system-complete-design-20260121.md section 6.1
|
||||
*/
|
||||
|
||||
import { join } from 'path';
|
||||
import { readdir, readFile, writeFile } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import type { RouteContext } from './types.js';
|
||||
import type { Task } from '../../types/loop.js';
|
||||
|
||||
/**
|
||||
* Handle task routes
|
||||
* @returns true if route was handled, false otherwise
|
||||
*/
|
||||
export async function handleTaskRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, req, res, initialPath, handlePostRequest } = ctx;
|
||||
|
||||
// Get workflow directory from initialPath
|
||||
const workflowDir = initialPath || process.cwd();
|
||||
const taskDir = join(workflowDir, '.task');
|
||||
|
||||
// GET /api/tasks - List all tasks
|
||||
if (pathname === '/api/tasks' && req.method === 'GET') {
|
||||
try {
|
||||
// Ensure task directory exists
|
||||
if (!existsSync(taskDir)) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, data: [], total: 0 }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Read all task files
|
||||
const files = await readdir(taskDir);
|
||||
const taskFiles = files.filter(f => f.endsWith('.json'));
|
||||
|
||||
const tasks: Task[] = [];
|
||||
for (const file of taskFiles) {
|
||||
try {
|
||||
const filePath = join(taskDir, file);
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
const task = JSON.parse(content) as Task;
|
||||
tasks.push(task);
|
||||
} catch (error) {
|
||||
// Skip invalid task files
|
||||
console.error('Failed to read task file ' + file + ':', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
const url = new URL(req.url || '', `http://localhost`);
|
||||
const loopOnly = url.searchParams.get('loop_only') === 'true';
|
||||
const filterStatus = url.searchParams.get('filter'); // active | completed
|
||||
|
||||
// Apply filters
|
||||
let filteredTasks = tasks;
|
||||
|
||||
// Filter by loop_control.enabled
|
||||
if (loopOnly) {
|
||||
filteredTasks = filteredTasks.filter(t => t.loop_control?.enabled);
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (filterStatus) {
|
||||
filteredTasks = filteredTasks.filter(t => t.status === filterStatus);
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
data: filteredTasks,
|
||||
total: filteredTasks.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;
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/tasks - Create new task
|
||||
if (pathname === '/api/tasks' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const task = body as Partial<Task>;
|
||||
|
||||
// Validate required fields
|
||||
if (!task.id) {
|
||||
return { success: false, error: 'Task ID is required', status: 400 };
|
||||
}
|
||||
|
||||
// Sanitize taskId to prevent path traversal
|
||||
if (task.id.includes('/') || task.id.includes('\\') || task.id === '..' || task.id === '.') {
|
||||
return { success: false, error: 'Invalid task ID format', status: 400 };
|
||||
}
|
||||
|
||||
if (!task.loop_control) {
|
||||
return { success: false, error: 'loop_control is required', status: 400 };
|
||||
}
|
||||
|
||||
if (!task.loop_control.enabled) {
|
||||
return { success: false, error: 'loop_control.enabled must be true', status: 400 };
|
||||
}
|
||||
|
||||
if (!task.loop_control.cli_sequence || task.loop_control.cli_sequence.length === 0) {
|
||||
return { success: false, error: 'cli_sequence must contain at least one step', status: 400 };
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure task directory exists
|
||||
const { mkdir } = await import('fs/promises');
|
||||
if (!existsSync(taskDir)) {
|
||||
await mkdir(taskDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Check if task already exists
|
||||
const taskPath = join(taskDir, task.id + '.json');
|
||||
if (existsSync(taskPath)) {
|
||||
return { success: false, error: 'Task already exists: ' + task.id, status: 409 };
|
||||
}
|
||||
|
||||
// Build complete task object
|
||||
const fullTask: Task = {
|
||||
id: task.id,
|
||||
title: task.title || task.id,
|
||||
description: task.description || task.loop_control?.description || '',
|
||||
status: task.status || 'active',
|
||||
meta: task.meta,
|
||||
context: task.context,
|
||||
loop_control: task.loop_control
|
||||
};
|
||||
|
||||
// Write task file
|
||||
await writeFile(taskPath, JSON.stringify(fullTask, null, 2), 'utf-8');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
task: fullTask,
|
||||
path: taskPath
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message, status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/tasks/validate - Validate task loop_control configuration
|
||||
if (pathname === '/api/tasks/validate' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const task = body as Partial<Task>;
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Validate loop_control
|
||||
if (!task.loop_control) {
|
||||
errors.push('loop_control is required');
|
||||
} else {
|
||||
// Check enabled flag
|
||||
if (typeof task.loop_control.enabled !== 'boolean') {
|
||||
errors.push('loop_control.enabled must be a boolean');
|
||||
}
|
||||
|
||||
// Check cli_sequence
|
||||
if (!task.loop_control.cli_sequence || !Array.isArray(task.loop_control.cli_sequence)) {
|
||||
errors.push('loop_control.cli_sequence must be an array');
|
||||
} else if (task.loop_control.cli_sequence.length === 0) {
|
||||
errors.push('loop_control.cli_sequence must contain at least one step');
|
||||
} else {
|
||||
// Validate each step
|
||||
task.loop_control.cli_sequence.forEach((step, index) => {
|
||||
if (!step.step_id) {
|
||||
errors.push(`Step ${index + 1}: step_id is required`);
|
||||
}
|
||||
if (!step.tool) {
|
||||
errors.push(`Step ${index + 1}: tool is required`);
|
||||
} else if (!['gemini', 'qwen', 'codex', 'claude', 'bash'].includes(step.tool)) {
|
||||
warnings.push(`Step ${index + 1}: unknown tool '${step.tool}'`);
|
||||
}
|
||||
if (!step.prompt_template && step.tool !== 'bash') {
|
||||
errors.push(`Step ${index + 1}: prompt_template is required for non-bash steps`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check max_iterations
|
||||
if (task.loop_control.max_iterations !== undefined) {
|
||||
if (typeof task.loop_control.max_iterations !== 'number' || task.loop_control.max_iterations < 1) {
|
||||
errors.push('loop_control.max_iterations must be a positive number');
|
||||
}
|
||||
if (task.loop_control.max_iterations > 100) {
|
||||
warnings.push('max_iterations > 100 may cause long execution times');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return validation result
|
||||
const isValid = errors.length === 0;
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
valid: isValid,
|
||||
errors,
|
||||
warnings
|
||||
}
|
||||
};
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// PUT /api/tasks/:taskId - Update existing task
|
||||
if (pathname.match(/^\/api\/tasks\/[^/]+$/) && req.method === 'PUT') {
|
||||
const taskId = pathname.split('/').pop();
|
||||
if (!taskId) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'Task ID required' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Sanitize taskId to prevent path traversal
|
||||
if (taskId.includes('/') || taskId.includes('\\') || taskId === '..' || taskId === '.') {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'Invalid task ID format' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const updates = body as Partial<Task>;
|
||||
const taskPath = join(taskDir, taskId + '.json');
|
||||
|
||||
// Check if task exists
|
||||
if (!existsSync(taskPath)) {
|
||||
return { success: false, error: 'Task not found: ' + taskId, status: 404 };
|
||||
}
|
||||
|
||||
try {
|
||||
// Read existing task
|
||||
const existingContent = await readFile(taskPath, 'utf-8');
|
||||
const existingTask = JSON.parse(existingContent) as Task;
|
||||
|
||||
// Merge updates (preserve id)
|
||||
const updatedTask: Task = {
|
||||
...existingTask,
|
||||
...updates,
|
||||
id: existingTask.id // Prevent id change
|
||||
};
|
||||
|
||||
// If loop_control is being updated, merge it properly
|
||||
if (updates.loop_control) {
|
||||
updatedTask.loop_control = {
|
||||
...existingTask.loop_control,
|
||||
...updates.loop_control
|
||||
};
|
||||
}
|
||||
|
||||
// Write updated task
|
||||
await writeFile(taskPath, JSON.stringify(updatedTask, null, 2), 'utf-8');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
task: updatedTask,
|
||||
path: taskPath
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message, status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/tasks/:taskId - Get specific task
|
||||
if (pathname.match(/^\/api\/tasks\/[^/]+$/) && req.method === 'GET') {
|
||||
const taskId = pathname.split('/').pop();
|
||||
if (!taskId) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'Task ID required' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Sanitize taskId to prevent path traversal
|
||||
if (taskId.includes('/') || taskId.includes('\\') || taskId === '..' || taskId === '.') {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'Invalid task ID format' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const taskPath = join(taskDir, taskId + '.json');
|
||||
|
||||
if (!existsSync(taskPath)) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'Task not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const content = await readFile(taskPath, 'utf-8');
|
||||
const task = JSON.parse(content) as Task;
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, data: task }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
312
ccw/src/core/routes/test-loop-routes.ts
Normal file
312
ccw/src/core/routes/test-loop-routes.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Test Loop Routes - Mock CLI endpoints for Loop system testing
|
||||
* Provides simulated CLI tool responses for testing Loop workflows
|
||||
*/
|
||||
|
||||
import type { RouteContext } from './types.js';
|
||||
|
||||
/**
|
||||
* Mock execution history storage
|
||||
* In production, this would be actual CLI execution results
|
||||
*/
|
||||
const mockExecutionStore = new Map<string, any[]>();
|
||||
|
||||
/**
|
||||
* Mock CLI tool responses
|
||||
*/
|
||||
const mockResponses = {
|
||||
// Bash mock responses
|
||||
bash: {
|
||||
npm_test_pass: {
|
||||
exitCode: 0,
|
||||
stdout: 'Test Suites: 1 passed, 1 total\nTests: 15 passed, 15 total\nSnapshots: 0 total\nTime: 2.345 s\nAll tests passed!',
|
||||
stderr: ''
|
||||
},
|
||||
npm_test_fail: {
|
||||
exitCode: 1,
|
||||
stdout: 'Test Suites: 1 failed, 1 total\nTests: 14 passed, 1 failed, 15 total',
|
||||
stderr: 'FAIL src/utils/validation.test.js\n \u251c Validation should reject invalid input\n Error: expect(received).toBe(true)\n Received: false\n at validation.test.js:42:18'
|
||||
},
|
||||
npm_lint: {
|
||||
exitCode: 0,
|
||||
stdout: 'Linting complete!\n0 errors, 2 warnings',
|
||||
stderr: ''
|
||||
},
|
||||
npm_benchmark_slow: {
|
||||
exitCode: 0,
|
||||
stdout: 'Running benchmark...\nOperation: 10000 ops\nAverage: 125ms\nMin: 110ms\nMax: 145ms',
|
||||
stderr: ''
|
||||
},
|
||||
npm_benchmark_fast: {
|
||||
exitCode: 0,
|
||||
stdout: 'Running benchmark...\nOperation: 10000 ops\nAverage: 35ms\nMin: 28ms\nMax: 42ms',
|
||||
stderr: ''
|
||||
}
|
||||
},
|
||||
// Gemini mock responses
|
||||
gemini: {
|
||||
analyze_failure: `## Root Cause Analysis
|
||||
|
||||
### Failed Test
|
||||
- Test: Validation should reject invalid input
|
||||
- File: src/utils/validation.test.js:42
|
||||
|
||||
### Error Analysis
|
||||
The validation function is not properly checking for empty strings. The test expects \`true\` for validation result, but receives \`false\`.
|
||||
|
||||
### Affected Files
|
||||
- src/utils/validation.js
|
||||
|
||||
### Fix Suggestion
|
||||
Update the validation function to handle empty string case:
|
||||
\`\`\`javascript
|
||||
function validateInput(input) {
|
||||
if (!input || input.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
// ... rest of validation
|
||||
}
|
||||
\`\`\``,
|
||||
analyze_performance: `## Performance Analysis
|
||||
|
||||
### Current Performance
|
||||
- Average: 125ms per operation
|
||||
- Target: < 50ms
|
||||
|
||||
### Bottleneck Identified
|
||||
The main loop in src/processor.js has O(n²) complexity due to nested array operations.
|
||||
|
||||
### Optimization Suggestion
|
||||
Replace nested forEach with Map-based lookup to achieve O(n) complexity.`,
|
||||
code_review: `## Code Review Summary
|
||||
|
||||
### Overall Assessment: LGTM
|
||||
|
||||
### Findings
|
||||
- Code structure is clear
|
||||
- Error handling is appropriate
|
||||
- Comments are sufficient
|
||||
|
||||
### Score: 9/10`
|
||||
},
|
||||
// Codex mock responses
|
||||
codex: {
|
||||
fix_validation: `Modified files:
|
||||
- src/utils/validation.js
|
||||
|
||||
Changes:
|
||||
Added empty string check in validateInput function:
|
||||
\`\`\`javascript
|
||||
function validateInput(input) {
|
||||
// Check for null, undefined, or empty string
|
||||
if (!input || typeof input !== 'string' || input.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
// ... existing validation logic
|
||||
}
|
||||
\`\`\``,
|
||||
optimize_performance: `Modified files:
|
||||
- src/processor.js
|
||||
|
||||
Changes:
|
||||
Replaced nested forEach with Map-based lookup:
|
||||
\`\`\`javascript
|
||||
// Before: O(n²)
|
||||
items.forEach(item => {
|
||||
otherItems.forEach(other => {
|
||||
if (item.id === other.id) { /* ... */ }
|
||||
});
|
||||
});
|
||||
|
||||
// After: O(n)
|
||||
const lookup = new Map(otherItems.map(o => [o.id, o]));
|
||||
items.forEach(item => {
|
||||
const other = lookup.get(item.id);
|
||||
if (other) { /* ... */ }
|
||||
});
|
||||
\`\`\``,
|
||||
add_tests: `Modified files:
|
||||
- tests/utils/math.test.js
|
||||
|
||||
Added new test cases:
|
||||
- testAddition()
|
||||
- testSubtraction()
|
||||
- testMultiplication()
|
||||
- testDivision()`
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle test loop routes
|
||||
* Provides mock CLI endpoints for testing Loop workflows
|
||||
*/
|
||||
export async function handleTestLoopRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, req, res, initialPath, handlePostRequest } = ctx;
|
||||
const workflowDir = initialPath || process.cwd();
|
||||
|
||||
// Only handle test routes in test mode
|
||||
if (!pathname.startsWith('/api/test/loop')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// GET /api/test/loop/mock/reset - Reset mock execution store
|
||||
if (pathname === '/api/test/loop/mock/reset' && req.method === 'POST') {
|
||||
mockExecutionStore.clear();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, message: 'Mock execution store reset' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/test/loop/mock/history - Get mock execution history
|
||||
if (pathname === '/api/test/loop/mock/history' && req.method === 'GET') {
|
||||
const history = Array.from(mockExecutionStore.entries()).map(([loopId, records]) => ({
|
||||
loopId,
|
||||
records
|
||||
}));
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, data: history }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/test/loop/mock/cli/execute - Mock CLI execution
|
||||
if (pathname === '/api/test/loop/mock/cli/execute' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { loopId, stepId, tool, command, prompt } = body as {
|
||||
loopId?: string;
|
||||
stepId?: string;
|
||||
tool?: string;
|
||||
command?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
|
||||
if (!loopId || !stepId || !tool) {
|
||||
return { success: false, error: 'loopId, stepId, and tool are required', status: 400 };
|
||||
}
|
||||
|
||||
// Simulate execution delay
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Get mock response based on tool and command/prompt
|
||||
let mockResult: any;
|
||||
|
||||
if (tool === 'bash') {
|
||||
if (command?.includes('test')) {
|
||||
// Determine pass/fail based on iteration
|
||||
const history = mockExecutionStore.get(loopId) || [];
|
||||
const iterationCount = history.filter(r => r.stepId === 'run_tests').length;
|
||||
mockResult = iterationCount >= 2 ? mockResponses.bash.npm_test_pass : mockResponses.bash.npm_test_fail;
|
||||
} else if (command?.includes('lint')) {
|
||||
mockResult = mockResponses.bash.npm_lint;
|
||||
} else if (command?.includes('benchmark')) {
|
||||
const history = mockExecutionStore.get(loopId) || [];
|
||||
const iterationCount = history.filter(r => r.stepId === 'run_benchmark').length;
|
||||
mockResult = iterationCount >= 3 ? mockResponses.bash.npm_benchmark_fast : mockResponses.bash.npm_benchmark_slow;
|
||||
} else {
|
||||
mockResult = { exitCode: 0, stdout: 'Command executed', stderr: '' };
|
||||
}
|
||||
} else if (tool === 'gemini') {
|
||||
if (prompt?.includes('failure')) {
|
||||
mockResult = { exitCode: 0, stdout: mockResponses.gemini.analyze_failure, stderr: '' };
|
||||
} else if (prompt?.includes('performance')) {
|
||||
mockResult = { exitCode: 0, stdout: mockResponses.gemini.analyze_performance, stderr: '' };
|
||||
} else if (prompt?.includes('review')) {
|
||||
mockResult = { exitCode: 0, stdout: mockResponses.gemini.code_review, stderr: '' };
|
||||
} else {
|
||||
mockResult = { exitCode: 0, stdout: 'Analysis complete', stderr: '' };
|
||||
}
|
||||
} else if (tool === 'codex') {
|
||||
if (prompt?.includes('validation') || prompt?.includes('fix')) {
|
||||
mockResult = { exitCode: 0, stdout: mockResponses.codex.fix_validation, stderr: '' };
|
||||
} else if (prompt?.includes('performance') || prompt?.includes('optimize')) {
|
||||
mockResult = { exitCode: 0, stdout: mockResponses.codex.optimize_performance, stderr: '' };
|
||||
} else if (prompt?.includes('test')) {
|
||||
mockResult = { exitCode: 0, stdout: mockResponses.codex.add_tests, stderr: '' };
|
||||
} else {
|
||||
mockResult = { exitCode: 0, stdout: 'Code modified successfully', stderr: '' };
|
||||
}
|
||||
} else {
|
||||
mockResult = { exitCode: 0, stdout: 'Execution complete', stderr: '' };
|
||||
}
|
||||
|
||||
// Store execution record
|
||||
if (!mockExecutionStore.has(loopId)) {
|
||||
mockExecutionStore.set(loopId, []);
|
||||
}
|
||||
mockExecutionStore.get(loopId)!.push({
|
||||
loopId,
|
||||
stepId,
|
||||
tool,
|
||||
command: command || prompt || 'N/A',
|
||||
...mockResult,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
exitCode: mockResult.exitCode,
|
||||
stdout: mockResult.stdout,
|
||||
stderr: mockResult.stderr
|
||||
}
|
||||
};
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/test/loop/run-full-scenario - Run a complete test scenario
|
||||
if (pathname === '/api/test/loop/run-full-scenario' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { scenario } = body as { scenario?: string };
|
||||
|
||||
// Reset mock store
|
||||
mockExecutionStore.clear();
|
||||
|
||||
const scenarios: Record<string, any> = {
|
||||
'test-fix': {
|
||||
description: 'Test-Fix Loop Scenario',
|
||||
steps: [
|
||||
{ stepId: 'run_tests', tool: 'bash', command: 'npm test', expectedToFail: true },
|
||||
{ stepId: 'analyze_failure', tool: 'gemini', prompt: 'Analyze failure' },
|
||||
{ stepId: 'apply_fix', tool: 'codex', prompt: 'Apply fix' },
|
||||
{ stepId: 'run_tests', tool: 'bash', command: 'npm test', expectedToPass: true }
|
||||
]
|
||||
},
|
||||
'performance-opt': {
|
||||
description: 'Performance Optimization Loop Scenario',
|
||||
steps: [
|
||||
{ stepId: 'run_benchmark', tool: 'bash', command: 'npm run benchmark', expectedSlow: true },
|
||||
{ stepId: 'analyze_bottleneck', tool: 'gemini', prompt: 'Analyze performance' },
|
||||
{ stepId: 'optimize', tool: 'codex', prompt: 'Optimize code' },
|
||||
{ stepId: 'run_benchmark', tool: 'bash', command: 'npm run benchmark', expectedFast: true }
|
||||
]
|
||||
},
|
||||
'doc-review': {
|
||||
description: 'Documentation Review Loop Scenario',
|
||||
steps: [
|
||||
{ stepId: 'generate_docs', tool: 'bash', command: 'npm run docs' },
|
||||
{ stepId: 'review_docs', tool: 'gemini', prompt: 'Review documentation' },
|
||||
{ stepId: 'fix_docs', tool: 'codex', prompt: 'Fix documentation issues' },
|
||||
{ stepId: 'final_review', tool: 'gemini', prompt: 'Final review' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const selectedScenario = scenarios[scenario || 'test-fix'];
|
||||
if (!selectedScenario) {
|
||||
return { success: false, error: 'Invalid scenario. Available: test-fix, performance-opt, doc-review', status: 400 };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
scenario: selectedScenario.description,
|
||||
steps: selectedScenario.steps,
|
||||
instructions: 'Use POST /api/test/loop/mock/cli/execute for each step'
|
||||
}
|
||||
};
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -28,6 +28,9 @@ import { handleLiteLLMRoutes } from './routes/litellm-routes.js';
|
||||
import { handleLiteLLMApiRoutes } from './routes/litellm-api-routes.js';
|
||||
import { handleNavStatusRoutes } from './routes/nav-status-routes.js';
|
||||
import { handleAuthRoutes } from './routes/auth-routes.js';
|
||||
import { handleLoopRoutes } from './routes/loop-routes.js';
|
||||
import { handleTestLoopRoutes } from './routes/test-loop-routes.js';
|
||||
import { handleTaskRoutes } from './routes/task-routes.js';
|
||||
|
||||
// Import WebSocket handling
|
||||
import { handleWebSocketUpgrade, broadcastToClients, extractSessionIdFromPath } from './websocket.js';
|
||||
@@ -102,7 +105,8 @@ const MODULE_CSS_FILES = [
|
||||
'31-api-settings.css',
|
||||
'32-issue-manager.css',
|
||||
'33-cli-stream-viewer.css',
|
||||
'34-discovery.css'
|
||||
'34-discovery.css',
|
||||
'36-loop-monitor.css'
|
||||
];
|
||||
|
||||
// Modular JS files in dependency order
|
||||
@@ -162,6 +166,7 @@ const MODULE_FILES = [
|
||||
'views/help.js',
|
||||
'views/issue-manager.js',
|
||||
'views/issue-discovery.js',
|
||||
'views/loop-monitor.js',
|
||||
'main.js'
|
||||
];
|
||||
|
||||
@@ -359,7 +364,14 @@ function generateServerDashboard(initialPath: string): string {
|
||||
// Read and concatenate modular JS files in dependency order
|
||||
let jsContent = MODULE_FILES.map(file => {
|
||||
const filePath = join(MODULE_JS_DIR, file);
|
||||
return existsSync(filePath) ? readFileSync(filePath, 'utf8') : '';
|
||||
if (!existsSync(filePath)) {
|
||||
console.error(`[Dashboard] Critical module file not found: ${filePath}`);
|
||||
console.error(`[Dashboard] Expected path relative to: ${MODULE_JS_DIR}`);
|
||||
console.error(`[Dashboard] Check that the file exists and is included in the build.`);
|
||||
// Return empty string with error comment to make the issue visible in browser
|
||||
return `console.error('[Dashboard] Module not loaded: ${file} (see server console for details)');\n`;
|
||||
}
|
||||
return readFileSync(filePath, 'utf8');
|
||||
}).join('\n\n');
|
||||
|
||||
// Inject CSS content
|
||||
@@ -556,6 +568,21 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
if (await handleCcwRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// Loop routes (/api/loops*)
|
||||
if (pathname.startsWith('/api/loops')) {
|
||||
if (await handleLoopRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// Task routes (/api/tasks)
|
||||
if (pathname.startsWith('/api/tasks')) {
|
||||
if (await handleTaskRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// Test loop routes (/api/test/loop*)
|
||||
if (pathname.startsWith('/api/test/loop')) {
|
||||
if (await handleTestLoopRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// Skills routes (/api/skills*)
|
||||
if (pathname.startsWith('/api/skills')) {
|
||||
if (await handleSkillsRoutes(routeContext)) return;
|
||||
|
||||
@@ -5,6 +5,64 @@ import type { Duplex } from 'stream';
|
||||
// WebSocket clients for real-time notifications
|
||||
export const wsClients = new Set<Duplex>();
|
||||
|
||||
/**
|
||||
* WebSocket message types for Loop monitoring
|
||||
*/
|
||||
export type LoopMessageType =
|
||||
| 'LOOP_STATE_UPDATE'
|
||||
| 'LOOP_STEP_COMPLETED'
|
||||
| 'LOOP_COMPLETED'
|
||||
| 'LOOP_LOG_ENTRY';
|
||||
|
||||
/**
|
||||
* Loop State Update - fired when loop status changes
|
||||
*/
|
||||
export interface LoopStateUpdateMessage {
|
||||
type: 'LOOP_STATE_UPDATE';
|
||||
loop_id: string;
|
||||
status: 'created' | 'running' | 'paused' | 'completed' | 'failed';
|
||||
current_iteration: number;
|
||||
current_cli_step: number;
|
||||
updated_at: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loop Step Completed - fired when a CLI step finishes
|
||||
*/
|
||||
export interface LoopStepCompletedMessage {
|
||||
type: 'LOOP_STEP_COMPLETED';
|
||||
loop_id: string;
|
||||
step_id: string;
|
||||
exit_code: number;
|
||||
duration_ms: number;
|
||||
output: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loop Completed - fired when entire loop finishes
|
||||
*/
|
||||
export interface LoopCompletedMessage {
|
||||
type: 'LOOP_COMPLETED';
|
||||
loop_id: string;
|
||||
final_status: 'completed' | 'failed';
|
||||
total_iterations: number;
|
||||
reason?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loop Log Entry - fired for streaming log lines
|
||||
*/
|
||||
export interface LoopLogEntryMessage {
|
||||
type: 'LOOP_LOG_ENTRY';
|
||||
loop_id: string;
|
||||
step_id: string;
|
||||
line: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export function handleWebSocketUpgrade(req: IncomingMessage, socket: Duplex, _head: Buffer): void {
|
||||
const header = req.headers['sec-websocket-key'];
|
||||
const key = Array.isArray(header) ? header[0] : header;
|
||||
@@ -196,3 +254,49 @@ export function extractSessionIdFromPath(filePath: string): string | null {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loop-specific broadcast with throttling
|
||||
* Throttles LOOP_STATE_UPDATE messages to avoid flooding clients
|
||||
*/
|
||||
let lastLoopBroadcast = 0;
|
||||
const LOOP_BROADCAST_THROTTLE = 1000; // 1 second
|
||||
|
||||
export type LoopMessage =
|
||||
| Omit<LoopStateUpdateMessage, 'timestamp'>
|
||||
| Omit<LoopStepCompletedMessage, 'timestamp'>
|
||||
| Omit<LoopCompletedMessage, 'timestamp'>
|
||||
| Omit<LoopLogEntryMessage, 'timestamp'>;
|
||||
|
||||
/**
|
||||
* Broadcast loop state update with throttling
|
||||
*/
|
||||
export function broadcastLoopUpdate(message: LoopMessage): void {
|
||||
const now = Date.now();
|
||||
|
||||
// Throttle LOOP_STATE_UPDATE to reduce WebSocket traffic
|
||||
if (message.type === 'LOOP_STATE_UPDATE' && now - lastLoopBroadcast < LOOP_BROADCAST_THROTTLE) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastLoopBroadcast = now;
|
||||
|
||||
broadcastToClients({
|
||||
...message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast loop log entry (no throttling)
|
||||
* Used for streaming real-time logs to Dashboard
|
||||
*/
|
||||
export function broadcastLoopLog(loop_id: string, step_id: string, line: string): void {
|
||||
broadcastToClients({
|
||||
type: 'LOOP_LOG_ENTRY',
|
||||
loop_id,
|
||||
step_id,
|
||||
line,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user