diff --git a/ccw/src/cli.ts b/ccw/src/cli.ts index 732886a8..1273b76e 100644 --- a/ccw/src/cli.ts +++ b/ccw/src/cli.ts @@ -14,6 +14,7 @@ import { coreMemoryCommand } from './commands/core-memory.js'; import { hookCommand } from './commands/hook.js'; import { issueCommand } from './commands/issue.js'; import { workflowCommand } from './commands/workflow.js'; +import { loopCommand } from './commands/loop.js'; import { readFileSync, existsSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; @@ -172,7 +173,7 @@ export function run(argv: string[]): void { .description('Unified CLI tool executor (gemini/qwen/codex/claude)') .option('-p, --prompt ', 'Prompt text (alternative to positional argument)') .option('-f, --file ', 'Read prompt from file (best for multi-line prompts)') - .option('--tool ', 'CLI tool to use', 'gemini') + .option('--tool ', 'CLI tool to use (reads from cli-settings.json defaultTool if not specified)') .option('--mode ', 'Execution mode: analysis, write, auto', 'analysis') .option('-d, --debug', 'Enable debug logging for troubleshooting') .option('--model ', 'Model override') @@ -301,6 +302,13 @@ export function run(argv: string[]): void { .option('--queue ', 'Target queue ID for multi-queue operations') .action((subcommand, args, options) => issueCommand(subcommand, args, options)); + // Loop command - Loop management for multi-CLI orchestration + program + .command('loop [subcommand] [args...]') + .description('Loop management for automated multi-CLI execution') + .option('--session ', 'Specify workflow session') + .action((subcommand, args, options) => loopCommand(subcommand, args, options)); + // Workflow command - Workflow installation and management program .command('workflow [subcommand] [args...]') diff --git a/ccw/src/commands/cli.ts b/ccw/src/commands/cli.ts index b38ee864..bac9111a 100644 --- a/ccw/src/commands/cli.ts +++ b/ccw/src/commands/cli.ts @@ -30,6 +30,7 @@ import { } from '../tools/storage-manager.js'; import { getHistoryStore } from '../tools/cli-history-store.js'; import { createSpinner } from '../utils/ui.js'; +import { loadClaudeCliSettings } from '../tools/claude-cli-tools.js'; // Dashboard notification settings const DASHBOARD_PORT = process.env.CCW_PORT || 3456; @@ -548,7 +549,19 @@ async function statusAction(debug?: boolean): Promise { * @param {Object} options - CLI options */ async function execAction(positionalPrompt: string | undefined, options: CliExecOptions): Promise { - const { prompt: optionPrompt, file, tool = 'gemini', mode = 'analysis', model, cd, includeDirs, stream, resume, id, noNative, cache, injectMode, debug, uncommitted, base, commit, title, rule } = options; + const { prompt: optionPrompt, file, tool: userTool, mode = 'analysis', model, cd, includeDirs, stream, resume, id, noNative, cache, injectMode, debug, uncommitted, base, commit, title, rule } = options; + + // Determine the tool to use: explicit --tool option, or defaultTool from config + let tool = userTool; + if (!tool) { + try { + const settings = loadClaudeCliSettings(cd || process.cwd()); + tool = settings.defaultTool || 'gemini'; + } catch { + // Fallback to gemini if config cannot be loaded + tool = 'gemini'; + } + } // Enable debug mode if --debug flag is set if (debug) { diff --git a/ccw/src/commands/loop.ts b/ccw/src/commands/loop.ts new file mode 100644 index 00000000..94f58529 --- /dev/null +++ b/ccw/src/commands/loop.ts @@ -0,0 +1,344 @@ +/** + * Loop Command + * CCW Loop System - CLI interface for loop management + * Reference: .workflow/.scratchpad/loop-system-complete-design-20260121.md section 4.3 + */ + +import chalk from 'chalk'; +import { readFile } from 'fs/promises'; +import { join, resolve } from 'path'; +import { existsSync } from 'fs'; +import { LoopManager } from '../tools/loop-manager.js'; +import type { TaskLoopControl } from '../types/loop.js'; + +// Minimal Task interface for task config files +interface Task { + id: string; + title?: string; + loop_control?: TaskLoopControl; +} + +/** + * Read task configuration + */ +async function readTaskConfig(taskId: string, workflowDir: string): Promise { + const taskFile = join(workflowDir, '.task', `${taskId}.json`); + + if (!existsSync(taskFile)) { + throw new Error(`Task file not found: ${taskFile}`); + } + + const content = await readFile(taskFile, 'utf-8'); + return JSON.parse(content) as Task; +} + +/** + * Find active workflow session + */ +function findActiveSession(cwd: string): string | null { + const workflowDir = join(cwd, '.workflow', 'active'); + + if (!existsSync(workflowDir)) { + return null; + } + + const { readdirSync } = require('fs'); + const sessions = readdirSync(workflowDir).filter((d: string) => d.startsWith('WFS-')); + + if (sessions.length === 0) { + return null; + } + + if (sessions.length === 1) { + return join(cwd, '.workflow', 'active', sessions[0]); + } + + // Multiple sessions, require user to specify + console.error(chalk.red('\n Error: Multiple active sessions found:')); + sessions.forEach((s: string) => console.error(chalk.gray(` - ${s}`))); + console.error(chalk.yellow('\n Please specify session with --session \n')); + return null; +} + +/** + * Get status badge with color + */ +function getStatusBadge(status: string): string { + switch (status) { + case 'created': + return chalk.gray('○ created'); + case 'running': + return chalk.cyan('● running'); + case 'paused': + return chalk.yellow('⏸ paused'); + case 'completed': + return chalk.green('✓ completed'); + case 'failed': + return chalk.red('✗ failed'); + default: + return status; + } +} + +/** + * Format time ago + */ +function timeAgo(timestamp: string): string { + const now = Date.now(); + const then = new Date(timestamp).getTime(); + const diff = Math.floor((now - then) / 1000); + + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +/** + * Start action + */ +async function startAction(taskId: string, options: { session?: string }): Promise { + const currentCwd = process.cwd(); + + // Find workflow session + let sessionDir: string | null; + + if (options.session) { + sessionDir = join(currentCwd, '.workflow', 'active', options.session); + if (!existsSync(sessionDir)) { + console.error(chalk.red(`\n Error: Session not found: ${options.session}\n`)); + process.exit(1); + } + } else { + sessionDir = findActiveSession(currentCwd); + if (!sessionDir) { + console.error(chalk.red('\n Error: No active workflow session found.')); + console.error(chalk.gray(' Run "ccw workflow:plan" first to create a session.\n')); + process.exit(1); + } + } + + console.log(chalk.cyan(` Using session: ${sessionDir.split(/[\\/]/).pop()}`)); + + // Read task config + const task = await readTaskConfig(taskId, sessionDir); + + if (!task.loop_control?.enabled) { + console.error(chalk.red(`\n Error: Task ${taskId} does not have loop enabled.\n`)); + process.exit(1); + } + + // Start loop + const loopManager = new LoopManager(sessionDir); + const loopId = await loopManager.startLoop(task as any); // Task interface compatible + + console.log(chalk.green(`\n ✓ Loop started: ${loopId}`)); + console.log(chalk.dim(` Status: ccw loop status ${loopId}`)); + console.log(chalk.dim(` Pause: ccw loop pause ${loopId}`)); + console.log(chalk.dim(` Stop: ccw loop stop ${loopId}\n`)); +} + +/** + * Status action + */ +async function statusAction(loopId: string | undefined, options: { session?: string }): Promise { + const currentCwd = process.cwd(); + const sessionDir = options?.session + ? join(currentCwd, '.workflow', 'active', options.session) + : findActiveSession(currentCwd); + + if (!sessionDir) { + console.error(chalk.red('\n Error: No active session found.\n')); + process.exit(1); + } + + const loopManager = new LoopManager(sessionDir); + + if (loopId) { + // Show single loop detail + const state = await loopManager.getStatus(loopId); + + console.log(chalk.bold.cyan('\n Loop Status\n')); + console.log(` ${chalk.gray('ID:')} ${state.loop_id}`); + console.log(` ${chalk.gray('Task:')} ${state.task_id}`); + console.log(` ${chalk.gray('Status:')} ${getStatusBadge(state.status)}`); + console.log(` ${chalk.gray('Iteration:')} ${state.current_iteration}/${state.max_iterations}`); + console.log(` ${chalk.gray('Step:')} ${state.current_cli_step + 1}/${state.cli_sequence.length}`); + console.log(` ${chalk.gray('Created:')} ${state.created_at}`); + console.log(` ${chalk.gray('Updated:')} ${state.updated_at}`); + + if (state.failure_reason) { + console.log(` ${chalk.gray('Reason:')} ${chalk.red(state.failure_reason)}`); + } + + console.log(chalk.bold.cyan('\n CLI Sequence\n')); + state.cli_sequence.forEach((step, i) => { + const current = i === state.current_cli_step ? chalk.cyan('→') : ' '; + console.log(` ${current} ${i + 1}. ${chalk.bold(step.step_id)} (${step.tool})`); + }); + + if (state.execution_history && state.execution_history.length > 0) { + console.log(chalk.bold.cyan('\n Recent Executions\n')); + const recent = state.execution_history.slice(-5); + recent.forEach(exec => { + const status = exec.exit_code === 0 ? chalk.green('✓') : chalk.red('✗'); + console.log(` ${status} ${exec.step_id} (${exec.tool}) - ${(exec.duration_ms / 1000).toFixed(1)}s`); + }); + } + + console.log(); + } else { + // List all loops + const loops = await loopManager.listLoops(); + + if (loops.length === 0) { + console.log(chalk.yellow('\n No loops found.\n')); + return; + } + + console.log(chalk.bold.cyan('\n Active Loops\n')); + console.log(chalk.gray(' Status ID Iteration Task')); + console.log(chalk.gray(' ' + '─'.repeat(70))); + + loops.forEach(loop => { + const status = getStatusBadge(loop.status); + const iteration = `${loop.current_iteration}/${loop.max_iterations}`; + console.log(` ${status} ${chalk.dim(loop.loop_id.padEnd(35))} ${iteration.padEnd(9)} ${loop.task_id}`); + }); + + console.log(); + } +} + +/** + * Pause action + */ +async function pauseAction(loopId: string, options: { session?: string }): Promise { + const currentCwd = process.cwd(); + const sessionDir = options.session + ? join(currentCwd, '.workflow', 'active', options.session) + : findActiveSession(currentCwd); + + if (!sessionDir) { + console.error(chalk.red('\n Error: No active session found.\n')); + process.exit(1); + } + + const loopManager = new LoopManager(sessionDir); + await loopManager.pauseLoop(loopId); +} + +/** + * Resume action + */ +async function resumeAction(loopId: string, options: { session?: string }): Promise { + const currentCwd = process.cwd(); + const sessionDir = options.session + ? join(currentCwd, '.workflow', 'active', options.session) + : findActiveSession(currentCwd); + + if (!sessionDir) { + console.error(chalk.red('\n Error: No active session found.\n')); + process.exit(1); + } + + const loopManager = new LoopManager(sessionDir); + await loopManager.resumeLoop(loopId); +} + +/** + * Stop action + */ +async function stopAction(loopId: string, options: { session?: string }): Promise { + const currentCwd = process.cwd(); + const sessionDir = options.session + ? join(currentCwd, '.workflow', 'active', options.session) + : findActiveSession(currentCwd); + + if (!sessionDir) { + console.error(chalk.red('\n Error: No active session found.\n')); + process.exit(1); + } + + const loopManager = new LoopManager(sessionDir); + await loopManager.stopLoop(loopId); +} + +/** + * Loop command entry point + */ +export async function loopCommand( + subcommand: string, + args: string | string[], + options: any +): Promise { + const argsArray = Array.isArray(args) ? args : (args ? [args] : []); + + try { + switch (subcommand) { + case 'start': + if (!argsArray[0]) { + console.error(chalk.red('\n Error: Task ID is required\n')); + console.error(chalk.gray(' Usage: ccw loop start [--session ]\n')); + process.exit(1); + } + await startAction(argsArray[0], options); + break; + + case 'status': + await statusAction(argsArray[0], options); + break; + + case 'pause': + if (!argsArray[0]) { + console.error(chalk.red('\n Error: Loop ID is required\n')); + console.error(chalk.gray(' Usage: ccw loop pause \n')); + process.exit(1); + } + await pauseAction(argsArray[0], options); + break; + + case 'resume': + if (!argsArray[0]) { + console.error(chalk.red('\n Error: Loop ID is required\n')); + console.error(chalk.gray(' Usage: ccw loop resume \n')); + process.exit(1); + } + await resumeAction(argsArray[0], options); + break; + + case 'stop': + if (!argsArray[0]) { + console.error(chalk.red('\n Error: Loop ID is required\n')); + console.error(chalk.gray(' Usage: ccw loop stop \n')); + process.exit(1); + } + await stopAction(argsArray[0], options); + break; + + default: + // Show help + console.log(chalk.bold.cyan('\n CCW Loop System\n')); + console.log(' Manage automated CLI execution loops\n'); + console.log(' Subcommands:'); + console.log(chalk.gray(' start Start a new loop from task configuration')); + console.log(chalk.gray(' status [loop-id] Show loop status (all or specific)')); + console.log(chalk.gray(' pause Pause a running loop')); + console.log(chalk.gray(' resume Resume a paused loop')); + console.log(chalk.gray(' stop Stop a loop')); + console.log(); + console.log(' Options:'); + console.log(chalk.gray(' --session Specify workflow session')); + console.log(); + console.log(' Examples:'); + console.log(chalk.gray(' ccw loop start IMPL-3')); + console.log(chalk.gray(' ccw loop status')); + console.log(chalk.gray(' ccw loop status loop-IMPL-3-20260121120000')); + console.log(chalk.gray(' ccw loop pause loop-IMPL-3-20260121120000')); + console.log(); + } + } catch (error) { + console.error(chalk.red(`\n ✗ Error: ${error instanceof Error ? error.message : error}\n`)); + process.exit(1); + } +} diff --git a/ccw/src/core/routes/claude-routes.ts b/ccw/src/core/routes/claude-routes.ts index 8561a382..039982b0 100644 --- a/ccw/src/core/routes/claude-routes.ts +++ b/ccw/src/core/routes/claude-routes.ts @@ -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 { // 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 { 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 { const startTime = Date.now(); const result = await executeCliTool({ - tool: tool === 'qwen' ? 'qwen' : 'gemini', + tool: resolvedTool, prompt: cliPrompt, mode: 'analysis', format: 'plain', diff --git a/ccw/src/core/routes/cli-settings-routes.ts b/ccw/src/core/routes/cli-settings-routes.ts index 119994e7..1b451512 100644 --- a/ccw/src/core/routes/cli-settings-routes.ts +++ b/ccw/src/core/routes/cli-settings-routes.ts @@ -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 { + 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; } diff --git a/ccw/src/core/routes/codexlens/semantic-handlers.ts b/ccw/src/core/routes/codexlens/semantic-handlers.ts index ddd03948..f1eeca6a 100644 --- a/ccw/src/core/routes/codexlens/semantic-handlers.ts +++ b/ccw/src/core/routes/codexlens/semantic-handlers.ts @@ -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 { 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); diff --git a/ccw/src/core/routes/core-memory-routes.ts b/ccw/src/core/routes/core-memory-routes.ts index 325abf15..969cd3a4 100644 --- a/ccw/src/core/routes/core-memory-routes.ts +++ b/ccw/src/core/routes/core-memory-routes.ts @@ -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 { - 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({ diff --git a/ccw/src/core/routes/files-routes.ts b/ccw/src/core/routes/files-routes.ts index c74d46fa..e177be90 100644 --- a/ccw/src/core/routes/files-routes.ts +++ b/ccw/src/core/routes/files-routes.ts @@ -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 { 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 { 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) { diff --git a/ccw/src/core/routes/loop-routes.ts b/ccw/src/core/routes/loop-routes.ts new file mode 100644 index 00000000..f218e719 --- /dev/null +++ b/ccw/src/core/routes/loop-routes.ts @@ -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 { + 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(); + 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; + active_count: number; + success_rate: number; + avg_iterations: number; +} { + const byStatus: Record = {}; + + 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; +} diff --git a/ccw/src/core/routes/memory-routes.ts b/ccw/src/core/routes/memory-routes.ts index d53d3b6a..45e82177 100644 --- a/ccw/src/core/routes/memory-routes.ts +++ b/ccw/src/core/routes/memory-routes.ts @@ -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 { 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 diff --git a/ccw/src/core/routes/task-routes.ts b/ccw/src/core/routes/task-routes.ts new file mode 100644 index 00000000..c75bb85e --- /dev/null +++ b/ccw/src/core/routes/task-routes.ts @@ -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 { + 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; + + // 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; + 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; + 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; +} diff --git a/ccw/src/core/routes/test-loop-routes.ts b/ccw/src/core/routes/test-loop-routes.ts new file mode 100644 index 00000000..a6ed645d --- /dev/null +++ b/ccw/src/core/routes/test-loop-routes.ts @@ -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(); + +/** + * 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 { + 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 = { + '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; +} diff --git a/ccw/src/core/server.ts b/ccw/src/core/server.ts index 00920a33..342abe4c 100644 --- a/ccw/src/core/server.ts +++ b/ccw/src/core/server.ts @@ -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(); +/** + * 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 + | Omit + | Omit + | Omit; + +/** + * 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() + }); +} diff --git a/ccw/src/templates/dashboard-css/12-cli-legacy.css b/ccw/src/templates/dashboard-css/12-cli-legacy.css index 73be67ed..58b7e34a 100644 --- a/ccw/src/templates/dashboard-css/12-cli-legacy.css +++ b/ccw/src/templates/dashboard-css/12-cli-legacy.css @@ -66,6 +66,27 @@ color: hsl(var(--muted-foreground)); } +/* CLI status actions container */ +.cli-status-actions { + display: flex; + align-items: center; + gap: 0.375rem; +} + +/* Spin animation for sync icon */ +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.spin { + animation: spin 1s linear infinite; +} + .cli-tools-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); diff --git a/ccw/src/templates/dashboard-css/36-loop-monitor.css b/ccw/src/templates/dashboard-css/36-loop-monitor.css new file mode 100644 index 00000000..c35d8551 --- /dev/null +++ b/ccw/src/templates/dashboard-css/36-loop-monitor.css @@ -0,0 +1,1024 @@ +/* ========================================== + LOOP MONITOR STYLES + ========================================== */ + +/* Layout */ +.loop-monitor-layout { + display: grid; + grid-template-columns: 350px 1fr; + gap: 1.5rem; + height: calc(100vh - 200px); + min-height: 500px; +} + +/* Loop List Panel */ +.loop-list-panel { + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + border-bottom: 1px solid hsl(var(--border)); +} + +.panel-header h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.filter-group select { + padding: 0.375rem 0.75rem; + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font-size: 0.875rem; +} + +.loop-list { + flex: 1; + overflow-y: auto; + padding: 0.5rem; +} + +/* Loop Card */ +.loop-card { + padding: 0.75rem; + margin-bottom: 0.5rem; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-left-width: 4px; + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; +} + +/* Card shine effect on hover */ +.loop-card::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.05), transparent); + transition: left 0.5s; +} + +.loop-card:hover::before { + left: 100%; +} + +.loop-card:hover { + background: hsl(var(--hover)); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + transform: translateY(-1px); +} + +.loop-card.selected { + background: hsl(var(--primary) / 0.12); + border-color: hsl(var(--primary)); + box-shadow: 0 0 0 2px hsl(var(--primary) / 0.2); +} + +.loop-card-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.loop-status-indicator { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 0.75rem; + flex-shrink: 0; +} + +.loop-title { + font-weight: 600; + font-size: 0.875rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.loop-card-body { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.loop-meta { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); +} + +.loop-task-id { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 180px; +} + +.loop-status-text { + font-weight: 500; +} + +.loop-progress { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.loop-step-info { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); +} + +.loop-time { + font-size: 0.7rem; + color: hsl(var(--muted-foreground)); +} + +/* Progress Bar */ +.progress-bar { + flex: 1; + height: 6px; + background: hsl(var(--muted)); + border-radius: 3px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: hsl(var(--primary)); + border-radius: 3px; + transition: width 0.3s; +} + +.progress-text { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); + min-width: 60px; + text-align: right; +} + +/* Loop Detail Panel */ +.loop-detail-panel { + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + overflow-y: auto; + padding: 1.5rem; +} + +.empty-detail-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: hsl(var(--muted-foreground)); + gap: 1rem; +} + +/* Loop Detail */ +.loop-detail { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.detail-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 1rem; + border-bottom: 1px solid hsl(var(--border)); +} + +.detail-status { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-radius: 2rem; + font-weight: 600; +} + +.status-icon { + font-size: 1.25rem; +} + +.detail-actions { + display: flex; + gap: 0.5rem; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + border: none; +} + +.btn-success { + background: hsl(var(--success)); + color: white; +} + +.btn-success:hover { + background: hsl(var(--success) / 0.9); +} + +.btn-warning { + background: hsl(var(--warning)); + color: white; +} + +.btn-warning:hover { + background: hsl(var(--warning) / 0.9); +} + +.btn-danger { + background: hsl(var(--destructive)); + color: white; +} + +.btn-danger:hover { + background: hsl(var(--destructive) / 0.9); +} + +.detail-info { + padding: 1rem; + background: hsl(var(--muted) / 0.3); + border-radius: 0.5rem; +} + +.detail-title { + margin: 0 0 0.75rem 0; + font-size: 1.25rem; + font-weight: 700; +} + +.detail-meta { + display: flex; + flex-wrap: wrap; + gap: 1rem; + font-size: 0.875rem; + color: hsl(var(--muted-foreground)); +} + +.detail-meta span { + display: flex; + align-items: center; + gap: 0.375rem; +} + +/* Detail Section */ +.detail-section { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.detail-section h4 { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: hsl(var(--foreground)); +} + +.progress-group { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.progress-item { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.progress-item label { + font-size: 0.875rem; + font-weight: 500; + color: hsl(var(--muted-foreground)); +} + +/* CLI Sequence */ +.cli-sequence { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.cli-step { + display: flex; + gap: 0.75rem; + padding: 0.75rem; + background: hsl(var(--muted) / 0.3); + border-radius: 0.375rem; +} + +.step-marker { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: hsl(var(--muted)); + font-size: 0.875rem; + font-weight: 600; + flex-shrink: 0; +} + +.cli-step.current .step-marker { + background: hsl(var(--primary)); + color: white; +} + +.cli-step.completed .step-marker { + background: hsl(var(--success)); + color: white; +} + +.step-content { + flex: 1; + min-width: 0; +} + +.step-name { + font-weight: 600; + font-size: 0.875rem; + margin-bottom: 0.25rem; +} + +.step-prompt { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); + overflow: hidden; + text-overflow: ellipsis; +} + +/* Variables Grid */ +.variables-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.5rem; +} + +.variable-item { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.5rem; + background: hsl(var(--muted) / 0.3); + border-radius: 0.375rem; +} + +.variable-key { + font-size: 0.75rem; + font-weight: 600; + color: hsl(var(--muted-foreground)); +} + +.variable-value { + font-size: 0.875rem; + font-family: 'Consolas', 'Monaco', monospace; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Execution Timeline */ +.execution-timeline { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.timeline-iteration { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-left: 1rem; +} + +.timeline-iteration.current { + border-left: 3px solid hsl(var(--primary)); + padding-left: 0.875rem; +} + +.iteration-header { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; +} + +.iteration-marker { + font-size: 1rem; +} + +.timeline-iteration.completed .iteration-marker { + color: hsl(var(--success)); +} + +.timeline-iteration.current .iteration-marker { + color: hsl(var(--primary)); +} + +.iteration-steps { + display: flex; + flex-direction: column; + gap: 0.375rem; + padding-left: 1.5rem; +} + +.timeline-step { + display: flex; + gap: 0.75rem; + padding: 0.5rem; + background: hsl(var(--muted) / 0.2); + border-radius: 0.375rem; +} + +.step-status { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 0.75rem; + flex-shrink: 0; +} + +.step-status.success { + background: hsl(var(--success)); + color: white; +} + +.step-status.failed { + background: hsl(var(--destructive)); + color: white; +} + +.step-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.step-tool { + font-weight: 600; + font-size: 0.875rem; +} + +.step-time { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); +} + +.step-duration { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); +} + +.step-error { + font-size: 0.75rem; + color: hsl(var(--destructive)); + background: hsl(var(--destructive) / 0.1); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + margin-top: 0.25rem; +} + +/* Error Section */ +.error-section { + padding: 1rem; + background: hsl(var(--destructive) / 0.1); + border: 1px solid hsl(var(--destructive) / 0.3); + border-radius: 0.5rem; +} + +.error-section h4 { + margin: 0 0 0.5rem 0; + color: hsl(var(--destructive)); +} + +.error-message { + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.875rem; + color: hsl(var(--destructive)); + white-space: pre-wrap; + word-break: break-word; +} + +/* Notification */ +.notification { + position: fixed; + bottom: 1rem; + right: 1rem; + padding: 0.75rem 1.5rem; + border-radius: 0.5rem; + font-weight: 500; + z-index: 1000; + animation: slideIn 0.3s ease-out; +} + +.notification.success { + background: hsl(var(--success)); + color: white; +} + +.notification.error { + background: hsl(var(--destructive)); + color: white; +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Empty State */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 1rem; + color: hsl(var(--muted-foreground)); + gap: 0.75rem; +} + +/* Loading Spinner */ +.loading-spinner { + display: flex; + align-items: center; + justify-content: center; + padding: 3rem 1rem; + color: hsl(var(--muted-foreground)); +} + +/* Color Themes */ +.text-cyan-500 { color: #06b6d4; } +.bg-cyan-100 { background: #cffafe; } +.text-amber-500 { color: #f59e0b; } +.bg-amber-100 { background: #fef3c7; } +.text-emerald-500 { color: #10b981; } +.bg-emerald-100 { background: #d1fae5; } +.text-red-500 { color: #ef4444; } +.bg-red-100 { background: #fee2e2; } +.text-gray-400 { color: #9ca3af; } +.bg-gray-100 { background: #f3f4f6; } + +.border-l-cyan-500 { border-left-color: #06b6d4; } +.border-l-amber-500 { border-left-color: #f59e0b; } +.border-l-emerald-500 { border-left-color: #10b981; } +.border-l-red-500 { border-left-color: #ef4444; } +.border-l-gray-400 { border-left-color: #9ca3af; } + +.bg-cyan-light { background: hsl(var(--info) / 0.2); } +.text-cyan { color: hsl(var(--info)); } + +/* Dark mode adjustments */ +[data-theme="dark"] .text-cyan-500 { color: #22d3ee; } +[data-theme="dark"] .bg-cyan-100 { background: #164e63; } +[data-theme="dark"] .text-amber-500 { color: #fbbf24; } +[data-theme="dark"] .bg-amber-100 { background: #78350f; } +[data-theme="dark"] .text-emerald-500 { color: #34d399; } +[data-theme="dark"] .bg-emerald-100 { background: #065f46; } +[data-theme="dark"] .text-red-500 { color: #f87171; } +[data-theme="dark"] .bg-red-100 { background: #7f1d1d; } +[data-theme="dark"] .text-gray-400 { color: #9ca3af; } +[data-theme="dark"] .bg-gray-100 { background: #374151; } + +/* Responsive */ +@media (max-width: 1024px) { + .loop-monitor-layout { + grid-template-columns: 1fr; + height: auto; + } + + .loop-detail-panel { + min-height: 400px; + } + + .loop-list-panel { + border-right: none; + border-bottom: 1px solid hsl(var(--border)); + } +} + +/* ========================================== + * TASK CREATION STYLES + * ========================================== */ + +/* Header actions */ +.header-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.header-actions .btn { + padding: 0.375rem 0.75rem; + font-size: 0.875rem; +} + +/* Tasks list */ +.tasks-list-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.3); +} + +/* Task card */ +.task-card { + background: white; + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + margin-bottom: 0.75rem; + overflow: hidden; + transition: box-shadow 0.2s, transform 0.2s; +} + +.task-card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transform: translateY(-1px); +} + +.task-card-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + background: hsl(var(--muted) / 0.3); + border-bottom: 1px solid hsl(var(--border)); +} + +.task-title { + font-weight: 600; + color: hsl(var(--foreground)); +} + +.task-id { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); + font-family: monospace; +} + +.task-card-body { + padding: 1rem; +} + +.task-description { + color: hsl(var(--muted-foreground)); + font-size: 0.875rem; + margin-bottom: 0.75rem; + line-height: 1.5; +} + +.task-meta { + display: flex; + gap: 1rem; + font-size: 0.875rem; + color: hsl(var(--muted-foreground)); + margin-bottom: 0.75rem; +} + +.task-meta span { + display: flex; + align-items: center; + gap: 0.25rem; +} + +/* Modal styles */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.modal-content { + background: white; + border-radius: 0.75rem; + width: 100%; + max-width: 600px; + max-height: 90vh; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 20px 25px rgba(0, 0, 0, 0.15); +} + +.modal-content.modal-lg { + max-width: 800px; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.3); +} + +.modal-header h3 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; +} + +.modal-close { + background: none; + border: none; + cursor: pointer; + padding: 0.25rem; + border-radius: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + color: hsl(var(--muted-foreground)); + transition: background 0.2s; +} + +.modal-close:hover { + background: hsl(var(--muted) / 0.5); +} + +.modal-body { + padding: 1.5rem; + overflow-y: auto; + flex: 1; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 1rem 1.5rem; + border-top: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.3); +} + +/* Form styles */ +.form-section { + margin-bottom: 1.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid hsl(var(--border)); +} + +.form-section:last-child { + border-bottom: none; +} + +.form-section h4 { + margin: 0 0 1rem 0; + font-size: 1rem; + font-weight: 600; + color: hsl(var(--foreground)); +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + color: hsl(var(--foreground)); +} + +.form-control { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; + font-size: 0.875rem; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.form-control:focus { + outline: none; + border-color: hsl(var(--primary)); + box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1); +} + +textarea.form-control { + resize: vertical; + min-height: 80px; +} + +.form-row { + display: flex; + gap: 1rem; +} + +.form-row .form-group { + flex: 1; +} + +/* CLI Step Card */ +.cli-step-card { + background: hsl(var(--muted) / 0.3); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + padding: 1rem; + margin-bottom: 1rem; +} + +.cli-step-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.step-number { + font-weight: 600; + color: hsl(var(--primary)); +} + +.btn-text { + background: none; + border: none; + color: hsl(var(--destructive)); + cursor: pointer; + padding: 0.25rem; +} + +.btn-text:hover { + background: hsl(var(--destructive) / 0.1); + border-radius: 0.25rem; +} + +/* Bash field visibility */ +.cli-step-card .bash-only { + display: none; +} + +.cli-step-card .bash-only:not([style*="display: none"]) { + display: block; +} + +/* Button variants */ +.btn { + padding: 0.5rem 1rem; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + border: none; + display: inline-flex; + align-items: center; + gap: 0.375rem; + transition: background 0.2s, opacity 0.2s; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: hsl(var(--primary)); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: hsl(var(--primary) / 0.9); +} + +.btn-secondary { + background: hsl(var(--secondary)); + color: white; +} + +.btn-secondary:hover:not(:disabled) { + background: hsl(var(--secondary) / 0.9); +} + +.btn-success { + background: hsl(var(--success)); + color: white; +} + +.btn-success:hover:not(:disabled) { + background: hsl(var(--success) / 0.9); +} + +.btn-danger { + background: hsl(var(--destructive)); + color: white; +} + +.btn-danger:hover:not(:disabled) { + background: hsl(var(--destructive) / 0.9); +} + +.btn-sm { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; +} + +.w-full { + width: 100%; +} + +.flex { + display: flex; +} + +.flex-1 { + flex: 1; +} + +.justify-between { + justify-content: space-between; +} + +.items-center { + align-items: center; +} + +.mb-3 { + margin-bottom: 0.75rem; +} + +.mt-3 { + margin-top: 0.75rem; +} + +.mt-4 { + margin-top: 1rem; +} + +.mr-2 { + margin-right: 0.5rem; +} + +.text-sm { + font-size: 0.875rem; +} + +.text-gray-500 { + color: hsl(var(--muted-foreground)); +} + +.text-gray-400 { + color: #9ca3af; +} + +.small { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); +} + } +} diff --git a/ccw/src/templates/dashboard-js/components/cli-status.js b/ccw/src/templates/dashboard-js/components/cli-status.js index 350efad5..8c720948 100644 --- a/ccw/src/templates/dashboard-js/components/cli-status.js +++ b/ccw/src/templates/dashboard-js/components/cli-status.js @@ -771,9 +771,14 @@ function renderCliStatus() { container.innerHTML = `

CLI Tools

- +
+ + +
${ccwInstallHtml}
@@ -825,6 +830,62 @@ function setPromptFormat(format) { showRefreshToast(`Prompt format set to ${format.toUpperCase()}`, 'success'); } +/** + * Sync builtin tools availability with installed CLI tools + * Checks system PATH and updates cli-tools.json accordingly + */ +async function syncBuiltinTools() { + const syncButton = document.querySelector('[onclick="syncBuiltinTools()"]'); + if (syncButton) { + syncButton.disabled = true; + const icon = syncButton.querySelector('i'); + if (icon) icon.classList.add('spin'); + } + + try { + const response = await csrfFetch('/api/cli/settings/sync-tools', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) { + throw new Error('Sync failed'); + } + + const result = await response.json(); + + // Reload the config after sync + await loadCliToolsConfig(); + await loadAllStatuses(); + renderCliStatus(); + + // Show summary of changes + const { enabled, disabled, unchanged } = result.changes; + let message = 'Tools synced: '; + const parts = []; + if (enabled.length > 0) parts.push(`${enabled.join(', ')} enabled`); + if (disabled.length > 0) parts.push(`${disabled.join(', ')} disabled`); + if (unchanged.length > 0) parts.push(`${unchanged.length} unchanged`); + message += parts.join(', '); + + showRefreshToast(message, 'success'); + + // Also invalidate the CLI tool cache to ensure fresh checks + if (window.cacheManager) { + window.cacheManager.delete('cli-tools-status'); + } + } catch (err) { + console.error('Failed to sync tools:', err); + showRefreshToast('Failed to sync tools: ' + (err.message || String(err)), 'error'); + } finally { + if (syncButton) { + syncButton.disabled = false; + const icon = syncButton.querySelector('i'); + if (icon) icon.classList.remove('spin'); + } + } +} + function setSmartContextEnabled(enabled) { smartContextEnabled = enabled; localStorage.setItem('ccw-smart-context', enabled.toString()); diff --git a/ccw/src/templates/dashboard-js/components/navigation.js b/ccw/src/templates/dashboard-js/components/navigation.js index dde5db86..2d66f683 100644 --- a/ccw/src/templates/dashboard-js/components/navigation.js +++ b/ccw/src/templates/dashboard-js/components/navigation.js @@ -183,6 +183,14 @@ function initNavigation() { } else { console.error('renderIssueDiscovery not defined - please refresh the page'); } + } else if (currentView === 'loop-monitor') { + if (typeof renderLoopMonitor === 'function') { + renderLoopMonitor(); + // Register destroy function for cleanup + currentViewDestroy = window.destroyLoopMonitor; + } else { + console.error('renderLoopMonitor not defined - please refresh the page'); + } } }); }); @@ -231,6 +239,8 @@ function updateContentTitle() { titleEl.textContent = t('title.issueManager'); } else if (currentView === 'issue-discovery') { titleEl.textContent = t('title.issueDiscovery'); + } else if (currentView === 'loop-monitor') { + titleEl.textContent = t('title.loopMonitor') || 'Loop Monitor'; } else if (currentView === 'liteTasks') { const names = { 'lite-plan': t('title.litePlanSessions'), 'lite-fix': t('title.liteFixSessions'), 'multi-cli-plan': t('title.multiCliPlanSessions') || 'Multi-CLI Plan Sessions' }; titleEl.textContent = names[currentLiteType] || t('title.liteTasks'); diff --git a/ccw/src/templates/dashboard-js/i18n.js b/ccw/src/templates/dashboard-js/i18n.js index 9fac0b7f..76d48b92 100644 --- a/ccw/src/templates/dashboard-js/i18n.js +++ b/ccw/src/templates/dashboard-js/i18n.js @@ -87,6 +87,10 @@ const i18n = { 'nav.liteFix': 'Lite Fix', 'nav.multiCliPlan': 'Multi-CLI Plan', + // Sidebar - Loops section + 'nav.loops': 'Loops', + 'nav.loopMonitor': 'Monitor', + // Sidebar - MCP section 'nav.mcpServers': 'MCP Servers', 'nav.manage': 'Manage', @@ -2144,6 +2148,51 @@ const i18n = { 'title.issueManager': 'Issue Manager', 'title.issueDiscovery': 'Issue Discovery', + // Loop Monitor + 'title.loopMonitor': 'Loop Monitor', + 'loop.title': 'Loop Monitor', + 'loop.status.created': 'Created', + 'loop.status.running': 'Running', + 'loop.status.paused': 'Paused', + 'loop.status.completed': 'Completed', + 'loop.status.failed': 'Failed', + 'loop.tabs.timeline': 'Timeline', + 'loop.tabs.logs': 'Logs', + 'loop.tabs.variables': 'Variables', + 'loop.buttons.pause': 'Pause', + 'loop.buttons.resume': 'Resume', + 'loop.buttons.stop': 'Stop', + 'loop.buttons.retry': 'Retry', + 'loop.buttons.newLoop': 'New Loop', + 'loop.empty': 'No active loops', + 'loop.metric.iteration': 'Iteration', + 'loop.metric.step': 'Step', + 'loop.metric.duration': 'Duration', + 'loop.task.id': 'Task', + 'loop.created': 'Created', + 'loop.updated': 'Updated', + 'loop.progress': 'Progress', + 'loop.cliSequence': 'CLI Sequence', + 'loop.stateVariables': 'State Variables', + 'loop.executionHistory': 'Execution History', + 'loop.failureReason': 'Failure Reason', + 'loop.noLoopsFound': 'No loops found', + 'loop.selectLoop': 'Select a loop to view details', + 'loop.tasks': 'Tasks', + 'loop.createTaskTitle': 'Create Loop Task', + 'loop.loopsCount': 'loops', + 'loop.paused': 'Loop paused', + 'loop.resumed': 'Loop resumed', + 'loop.stopped': 'Loop stopped', + 'loop.startedSuccess': 'Loop started', + 'loop.taskDescription': 'Description', + 'loop.maxIterations': 'Max Iterations', + 'loop.errorPolicy': 'Error Policy', + 'loop.pauseOnError': 'Pause on error', + 'loop.retryAutomatically': 'Retry automatically', + 'loop.failImmediate': 'Fail immediately', + 'loop.successCondition': 'Success Condition', + // Issue Discovery 'discovery.title': 'Issue Discovery', 'discovery.description': 'Discover potential issues from multiple perspectives', @@ -2438,6 +2487,10 @@ const i18n = { 'nav.liteFix': '轻量修复', 'nav.multiCliPlan': '多CLI规划', + // Sidebar - Loops section + 'nav.loops': '循环', + 'nav.loopMonitor': '监控器', + // Sidebar - MCP section 'nav.mcpServers': 'MCP 服务器', 'nav.manage': '管理', @@ -4507,6 +4560,51 @@ const i18n = { 'title.issueManager': '议题管理器', 'title.issueDiscovery': '议题发现', + // Loop Monitor + 'title.loopMonitor': '循环监控', + 'loop.title': '循环监控', + 'loop.status.created': '已创建', + 'loop.status.running': '运行中', + 'loop.status.paused': '已暂停', + 'loop.status.completed': '已完成', + 'loop.status.failed': '失败', + 'loop.tabs.timeline': '时间线', + 'loop.tabs.logs': '日志', + 'loop.tabs.variables': '变量', + 'loop.buttons.pause': '暂停', + 'loop.buttons.resume': '恢复', + 'loop.buttons.stop': '停止', + 'loop.buttons.retry': '重试', + 'loop.buttons.newLoop': '新建循环', + 'loop.empty': '没有活跃的循环', + 'loop.metric.iteration': '迭代', + 'loop.metric.step': '步骤', + 'loop.metric.duration': '耗时', + 'loop.task.id': '任务', + 'loop.created': '创建时间', + 'loop.updated': '更新时间', + 'loop.progress': '进度', + 'loop.cliSequence': 'CLI 序列', + 'loop.stateVariables': '状态变量', + 'loop.executionHistory': '执行历史', + 'loop.failureReason': '失败原因', + 'loop.noLoopsFound': '未找到循环', + 'loop.selectLoop': '选择一个循环查看详情', + 'loop.tasks': '任务', + 'loop.createTaskTitle': '创建循环任务', + 'loop.loopsCount': '个循环', + 'loop.paused': '循环已暂停', + 'loop.resumed': '循环已恢复', + 'loop.stopped': '循环已停止', + 'loop.startedSuccess': '循环已启动', + 'loop.taskDescription': '描述', + 'loop.maxIterations': '最大迭代数', + 'loop.errorPolicy': '错误策略', + 'loop.pauseOnError': '错误时暂停', + 'loop.retryAutomatically': '自动重试', + 'loop.failImmediate': '立即失败', + 'loop.successCondition': '成功条件', + // Issue Discovery 'discovery.title': '议题发现', 'discovery.description': '从多个视角发现潜在问题', diff --git a/ccw/src/templates/dashboard-js/views/loop-monitor.js b/ccw/src/templates/dashboard-js/views/loop-monitor.js new file mode 100644 index 00000000..7a3ec995 --- /dev/null +++ b/ccw/src/templates/dashboard-js/views/loop-monitor.js @@ -0,0 +1,1002 @@ +// ========================================== +// LOOP MONITOR VIEW +// ========================================== + +// Loop state store for real-time updates +window.loopStateStore = {}; +window.selectedLoopId = null; +window.loopWebSocket = null; +window.loopReconnectAttempts = 0; +window.loopMaxReconnectAttempts = 10; + +// Status colors and icons +const loopStatusConfig = { + created: { icon: '○', label: 'Created', className: 'text-gray-400 bg-gray-100', border: 'border-l-gray-400' }, + running: { icon: '●', label: 'Running', className: 'text-cyan-500 bg-cyan-100 animate-pulse', border: 'border-l-cyan-500' }, + paused: { icon: '⏸', label: 'Paused', className: 'text-amber-500 bg-amber-100', border: 'border-l-amber-500' }, + completed: { icon: '✓', label: 'Completed', className: 'text-emerald-500 bg-emerald-100', border: 'border-l-emerald-500' }, + failed: { icon: '✗', label: 'Failed', className: 'text-red-500 bg-red-100', border: 'border-l-red-500' } +}; + +/** + * Render Loop Monitor view + */ +async function renderLoopMonitor() { + try { + // Hide stats and carousel if function exists + if (typeof hideStatsAndCarousel === 'function') { + hideStatsAndCarousel(); + } + + const container = document.getElementById('mainContent'); + if (!container) { + console.error('Main content container not found'); + return; + } + container.innerHTML = ` +
+
+ +
+
+

Loops

+
+ + + +
+
+
+
Loading loops...
+
+
+ + +
+
+ +

Select a loop to view details

+
+
+
+
+ `; + + if (typeof lucide !== 'undefined') lucide.createIcons(); + + // Initialize WebSocket connection (with error handling) + try { + initLoopWebSocket(); + } catch (err) { + console.error('Failed to initialize WebSocket:', err); + } + + // Load loops (with error handling) + try { + await loadLoops(); + } catch (err) { + console.error('Failed to load loops:', err); + showError('Failed to load loops: ' + (err.message || String(err))); + } + } catch (err) { + console.error('Failed to render Loop Monitor:', err); + showError('Failed to render Loop Monitor: ' + (err.message || String(err))); + } +} + +/** + * Initialize WebSocket for real-time updates + */ +function initLoopWebSocket() { + // Check max reconnect attempts + if (window.loopReconnectAttempts >= window.loopMaxReconnectAttempts) { + console.warn('Loop WebSocket max reconnection attempts reached, giving up'); + return; + } + + if (window.loopWebSocket) { + window.loopWebSocket.close(); + } + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws`; + + try { + window.loopWebSocket = new WebSocket(wsUrl); + + window.loopWebSocket.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + if (data.type === 'LOOP_STATE_UPDATE' || + data.type === 'LOOP_STEP_COMPLETED' || + data.type === 'LOOP_COMPLETED') { + handleLoopUpdate(data); + } + } catch (err) { + console.error('WebSocket message parse error:', err); + } + }; + + window.loopWebSocket.onopen = () => { + console.log('Loop WebSocket connected'); + window.loopReconnectAttempts = 0; // Reset on successful connection + }; + + window.loopWebSocket.onerror = (err) => { + console.error('Loop WebSocket error:', err); + }; + + window.loopWebSocket.onclose = () => { + window.loopReconnectAttempts++; + console.log('Loop WebSocket closed, reconnecting in 5s... (attempt ' + window.loopReconnectAttempts + '/' + window.loopMaxReconnectAttempts + ')'); + setTimeout(initLoopWebSocket, 5000); + }; + } catch (err) { + console.error('Failed to create WebSocket:', err); + } +} + +/** + * Handle real-time loop update + */ +function handleLoopUpdate(data) { + const loop = data.data; + if (!loop || !loop.loop_id) return; + + // Update store + window.loopStateStore[loop.loop_id] = loop; + + // Re-render + renderLoopList(); + if (window.selectedLoopId === loop.loop_id) { + renderLoopDetail(loop.loop_id); + } +} + +/** + * Load all loops from API + */ +async function loadLoops() { + try { + const response = await fetch('/api/loops'); + const result = await response.json(); + + if (result.success) { + result.data.forEach(loop => { + window.loopStateStore[loop.loop_id] = loop; + }); + renderLoopList(); + } else { + showError('Failed to load loops: ' + (result.error || 'Unknown error')); + } + } catch (err) { + console.error('Load loops error:', err); + showError('Failed to load loops: ' + err.message); + } +} + +/** + * Render loop list + */ +function renderLoopList() { + const container = document.getElementById('loopList'); + if (!container) return; + + const filter = document.getElementById('loopFilter')?.value || 'all'; + const loops = Object.values(window.loopStateStore); + + const filteredLoops = loops.filter(loop => { + if (filter === 'all') return true; + return loop.status === filter; + }); + + if (filteredLoops.length === 0) { + container.innerHTML = ` +
+ +

No loops found

+
+ `; + if (typeof lucide !== 'undefined') lucide.createIcons(); + return; + } + + container.innerHTML = filteredLoops.map(loop => renderLoopCard(loop)).join(''); + if (typeof lucide !== 'undefined') lucide.createIcons(); +} + +/** + * Render single loop card + */ +function renderLoopCard(loop) { + const config = loopStatusConfig[loop.status] || loopStatusConfig.created; + const isSelected = window.selectedLoopId === loop.loop_id; + + const progress = loop.max_iterations > 0 + ? Math.round((loop.current_iteration / loop.max_iterations) * 100) + : 0; + + return ` +
+
+ ${config.icon} + ${escapeHtml(loop.loop_id)} +
+
+
+ Task: ${escapeHtml(loop.task_id || 'N/A')} + ${config.label} +
+
+
+
+
+ ${loop.current_iteration}/${loop.max_iterations} (${progress}%) +
+
+ Step: ${loop.current_cli_step + 1}/${loop.cli_sequence?.length || 0} +
+
+ Updated: ${formatRelativeTime(loop.updated_at)} +
+
+
+ `; +} + +/** + * Select and show loop detail + */ +function selectLoop(loopId) { + window.selectedLoopId = loopId; + renderLoopList(); // Re-render to update selection + renderLoopDetail(loopId); +} + +/** + * Render loop detail panel + */ +function renderLoopDetail(loopId) { + const container = document.getElementById('loopDetailPanel'); + const loop = window.loopStateStore[loopId]; + + if (!loop) { + container.innerHTML = ` +
+ +

Loop not found

+
+ `; + if (typeof lucide !== 'undefined') lucide.createIcons(); + return; + } + + const config = loopStatusConfig[loop.status] || loopStatusConfig.created; + const iterProgress = loop.max_iterations > 0 + ? Math.round((loop.current_iteration / loop.max_iterations) * 100) + : 0; + const stepProgress = loop.cli_sequence?.length > 0 + ? Math.round(((loop.current_cli_step + 1) / loop.cli_sequence.length) * 100) + : 0; + + container.innerHTML = ` +
+ +
+
+ ${config.icon} + ${config.label} +
+
+ ${loop.status === 'running' ? ` + + ` : ''} + ${loop.status === 'paused' ? ` + + ` : ''} + ${(loop.status === 'running' || loop.status === 'paused') ? ` + + ` : ''} +
+
+ + +
+

${escapeHtml(loop.loop_id)}

+
+ Created: ${formatDateTime(loop.created_at)} + Updated: ${formatRelativeTime(loop.updated_at)} + Task: ${escapeHtml(loop.task_id || 'N/A')} +
+
+ + +
+

Progress

+
+
+ +
+
+
+ ${loop.current_iteration}/${loop.max_iterations} (${iterProgress}%) +
+
+ +
+
+
+ ${loop.current_cli_step + 1}/${loop.cli_sequence?.length || 0} +
+
+
+ + +
+

CLI Sequence

+
+ ${(loop.cli_sequence || []).map((step, index) => { + const isCurrent = index === loop.current_cli_step; + const isPast = index < loop.current_cli_step; + const stepStatus = isCurrent ? 'current' : (isPast ? 'completed' : 'pending'); + + return ` +
+
${isPast ? '✓' : (isCurrent ? '●' : index + 1)}
+
+
${escapeHtml(step.tool || 'unknown')}
+
${escapeHtml(step.prompt?.substring(0, 100) || '')}${step.prompt?.length > 100 ? '...' : ''}
+
+
+ `; + }).join('')} +
+
+ + + ${Object.keys(loop.state_variables || {}).length > 0 ? ` +
+

State Variables

+
+ ${Object.entries(loop.state_variables || {}).map(([key, value]) => ` +
+ ${escapeHtml(key)} + ${escapeHtml(value)} +
+ `).join('')} +
+
+ ` : ''} + + + ${(loop.execution_history?.length || 0) > 0 ? ` +
+

Execution History

+
+ ${renderExecutionTimeline(loop)} +
+
+ ` : ''} + + + ${loop.failure_reason ? ` +
+

Failure Reason

+
${escapeHtml(loop.failure_reason)}
+
+ ` : ''} +
+ `; + + if (typeof lucide !== 'undefined') lucide.createIcons(); +} + +/** + * Render execution timeline + */ +function renderExecutionTimeline(loop) { + const history = loop.execution_history || []; + const sequence = loop.cli_sequence || []; + + // Group by iteration + const iterations = {}; + history.forEach(record => { + if (!iterations[record.iteration]) { + iterations[record.iteration] = []; + } + iterations[record.iteration].push(record); + }); + + return Object.entries(iterations) + .sort(([a], [b]) => parseInt(a) - parseInt(b)) + .map(([iter, records]) => { + const isCurrent = parseInt(iter) === loop.current_iteration; + return ` +
+
+ ${isCurrent ? '●' : '✓'} + Iteration ${iter} +
+
+ ${records.map(record => ` +
+
+ ${record.success ? '✓' : '✗'} +
+
+
${escapeHtml(sequence[record.step_index]?.tool || 'unknown')}
+
${formatDateTime(record.started_at)}
+
${record.duration_ms}ms
+ ${!record.success && record.error ? ` +
${escapeHtml(record.error)}
+ ` : ''} +
+
+ `).join('')} +
+
+ `; + }).join(''); +} + +/** + * Filter loops by status + */ +function filterLoops() { + renderLoopList(); +} + +/** + * Pause loop + */ +async function pauseLoop(loopId) { + try { + const response = await fetch(`/api/loops/${loopId}/pause`, { method: 'POST' }); + const result = await response.json(); + + if (result.success) { + showNotification('Loop paused', 'success'); + await loadLoops(); + } else { + showError('Failed to pause: ' + (result.error || 'Unknown error')); + } + } catch (err) { + console.error('Pause loop error:', err); + showError('Failed to pause: ' + err.message); + } +} + +/** + * Resume loop + */ +async function resumeLoop(loopId) { + try { + const response = await fetch(`/api/loops/${loopId}/resume`, { method: 'POST' }); + const result = await response.json(); + + if (result.success) { + showNotification('Loop resumed', 'success'); + await loadLoops(); + } else { + showError('Failed to resume: ' + (result.error || 'Unknown error')); + } + } catch (err) { + console.error('Resume loop error:', err); + showError('Failed to resume: ' + err.message); + } +} + +/** + * Confirm and stop loop + */ +function confirmStopLoop(loopId) { + const loop = window.loopStateStore[loopId]; + if (!loop) return; + + if (confirm(`Stop loop ${loopId}?\n\nIteration: ${loop.current_iteration}/${loop.max_iterations}\nThis action cannot be undone.`)) { + stopLoop(loopId); + } +} + +/** + * Stop loop + */ +async function stopLoop(loopId) { + try { + const response = await fetch(`/api/loops/${loopId}/stop`, { method: 'POST' }); + const result = await response.json(); + + if (result.success) { + showNotification('Loop stopped', 'success'); + await loadLoops(); + } else { + showError('Failed to stop: ' + (result.error || 'Unknown error')); + } + } catch (err) { + console.error('Stop loop error:', err); + showError('Failed to stop: ' + err.message); + } +} + +/** + * Cleanup function for view transition + */ +window.destroyLoopMonitor = function() { + if (window.loopWebSocket) { + window.loopWebSocket.close(); + window.loopWebSocket = null; + } + window.selectedLoopId = null; + window.loopReconnectAttempts = 0; // Reset reconnect counter +}; + +// Helper functions +function escapeHtml(text) { + if (text == null) return ''; + const div = document.createElement('div'); + div.textContent = String(text); + return div.innerHTML; +} + +function formatDateTime(isoString) { + if (!isoString) return 'N/A'; + const date = new Date(isoString); + return date.toLocaleString(); +} + +function formatRelativeTime(isoString) { + if (!isoString) return 'N/A'; + const date = new Date(isoString); + const now = new Date(); + const diff = Math.floor((now - date) / 1000); + + if (diff < 60) return 'just now'; + if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; + if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; + return Math.floor(diff / 86400) + 'd ago'; +} + +function showNotification(message, type) { + const toast = document.createElement('div'); + toast.className = `notification ${type}`; + toast.textContent = message; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 3000); +} + +function showError(message) { + showNotification(message, 'error'); +} + +// ========================================== +// LOOP TASK CREATION +// ========================================== + +/** + * Show tasks tab with loop-enabled tasks + */ +async function showTasksTab() { + try { + const response = await fetch('/api/tasks'); + const result = await response.json(); + + if (!result.success) { + showError('Failed to load tasks: ' + (result.error || 'Unknown error')); + return; + } + + const tasks = result.data || []; + const loopEnabledTasks = tasks.filter(t => t.loop_control && t.loop_control.enabled); + + renderTasksList(loopEnabledTasks); + } catch (err) { + console.error('Load tasks error:', err); + showError('Failed to load tasks: ' + err.message); + } +} + +/** + * Render tasks list with start button + */ +function renderTasksList(tasks) { + const listContainer = document.getElementById('loopList'); + + if (tasks.length === 0) { + listContainer.innerHTML = ` +
+ +

No loop-enabled tasks found

+ +
+ `; + if (typeof lucide !== 'undefined') lucide.createIcons(); + return; + } + + listContainer.innerHTML = ` +
+

${tasks.length} task(s) with loop enabled

+ +
+
+ ${tasks.map(task => renderTaskCard(task)).join('')} +
+ `; + + if (typeof lucide !== 'undefined') lucide.createIcons(); +} + +/** + * Render single task card + */ +function renderTaskCard(task) { + const config = task.loop_control || {}; + const stepCount = config.cli_sequence ? config.cli_sequence.length : 0; + + return ` +
+
+ ${escapeHtml(task.title || task.id)} + ${escapeHtml(task.id)} +
+
+

${escapeHtml(config.description || 'No description')}

+
+ Max: ${config.max_iterations || 10} + Steps: ${stepCount} +
+ +
+
+ `; +} + +/** + * Start loop from task + */ +async function startLoopFromTask(taskId) { + try { + const response = await fetch('/api/loops', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ taskId }) + }); + const result = await response.json(); + + if (result.success) { + showNotification('Loop started: ' + result.data.loopId, 'success'); + await loadLoops(); // Refresh to show new loop + } else { + showError('Failed to start loop: ' + (result.error || 'Unknown error')); + } + } catch (err) { + console.error('Start loop error:', err); + showError('Failed to start loop: ' + err.message); + } +} + +/** + * Show create loop modal + */ +function showCreateLoopModal() { + const modal = document.createElement('div'); + modal.id = 'createLoopModal'; + modal.className = 'modal-overlay'; + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + if (typeof lucide !== 'undefined') lucide.createIcons(); + + // Add first step by default + addCliStep(); + + // Focus on title + setTimeout(() => document.getElementById('taskTitle').focus(), 100); +} + +/** + * Close create loop modal + */ +function closeCreateLoopModal() { + const modal = document.getElementById('createLoopModal'); + if (modal) { + modal.remove(); + } +} + +/** + * Add CLI step to form + */ +let stepCounter = 0; +function addCliStep() { + const container = document.getElementById('cliStepsContainer'); + const stepIndex = stepCounter++; + + const stepHtml = ` +
+
+ Step ${stepIndex + 1} + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ `; + + container.insertAdjacentHTML('beforeend', stepHtml); + if (typeof lucide !== 'undefined') lucide.createIcons(); +} + +/** + * Remove CLI step + */ +function removeCliStep(stepIndex) { + const step = document.querySelector(`.cli-step-card[data-step="${stepIndex}"]`); + if (step) { + step.remove(); + } +} + +/** + * Update step fields based on tool selection + */ +function updateStepFields(stepIndex) { + const toolSelect = document.querySelector(`select[name="step_${stepIndex}_tool"]`); + const bashField = document.querySelector(`.cli-step-card[data-step="${stepIndex}"] .bash-only`); + + if (toolSelect && bashField) { + if (toolSelect.value === 'bash') { + bashField.style.display = 'block'; + } else { + bashField.style.display = 'none'; + } + } +} + +/** + * Handle create loop form submission + */ +async function handleCreateLoopSubmit(event) { + event.preventDefault(); + + const form = event.target; + const formData = new FormData(form); + + // Collect CLI steps + const cliSequence = []; + const stepCards = document.querySelectorAll('.cli-step-card'); + + stepCards.forEach(card => { + const stepIndex = card.dataset.step; + const tool = formData.get(`step_${stepIndex}_tool`); + + const step = { + step_id: formData.get(`step_${stepIndex}_id`), + tool: tool, + mode: formData.get(`step_${stepIndex}_mode`), + on_error: formData.get(`step_${stepIndex}_on_error`) + }; + + if (tool === 'bash') { + step.command = formData.get(`step_${stepIndex}_command`); + } + if (formData.get(`step_${stepIndex}_prompt`)) { + step.prompt_template = formData.get(`step_${stepIndex}_prompt`); + } + + cliSequence.push(step); + }); + + // Build task object + const task = { + id: 'LOOP-' + Date.now(), + title: formData.get('title'), + status: 'active', + meta: { + type: 'loop', + created_by: 'dashboard' + }, + context: { + requirements: [formData.get('description')], + acceptance: [] + }, + loop_control: { + enabled: true, + description: formData.get('description'), + max_iterations: parseInt(formData.get('max_iterations')), + success_condition: formData.get('success_condition'), + error_policy: { + on_failure: formData.get('error_policy'), + max_retries: parseInt(formData.get('max_retries')) + }, + cli_sequence: cliSequence + } + }; + + try { + // Create task + const createResponse = await fetch('/api/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(task) + }); + const createResult = await createResponse.json(); + + if (!createResult.success) { + showError('Failed to create task: ' + (createResult.error || 'Unknown error')); + return; + } + + // Start loop + const startResponse = await fetch('/api/loops', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ taskId: task.id }) + }); + const startResult = await startResponse.json(); + + if (startResult.success) { + showNotification('Loop created and started: ' + startResult.data.loopId, 'success'); + closeCreateLoopModal(); + await loadLoops(); // Refresh to show new loop + } else { + showError('Task created but failed to start loop: ' + (startResult.error || 'Unknown error')); + } + } catch (err) { + console.error('Create loop error:', err); + showError('Failed to create loop: ' + err.message); + } +} diff --git a/ccw/src/templates/dashboard.html b/ccw/src/templates/dashboard.html index 08c70779..660d6f4b 100644 --- a/ccw/src/templates/dashboard.html +++ b/ccw/src/templates/dashboard.html @@ -525,6 +525,21 @@
+ +
+
+ + Loops +
+
    + +
+
+
diff --git a/ccw/src/tools/claude-cli-tools.ts b/ccw/src/tools/claude-cli-tools.ts index 6033447d..70621410 100644 --- a/ccw/src/tools/claude-cli-tools.ts +++ b/ccw/src/tools/claude-cli-tools.ts @@ -610,6 +610,19 @@ export function updateClaudeDefaultTool( return settings; } +/** + * Get the default tool from config + * Returns the configured defaultTool or 'gemini' as fallback + */ +export function getDefaultTool(projectDir: string): string { + try { + const settings = loadClaudeCliSettings(projectDir); + return settings.defaultTool || 'gemini'; + } catch { + return 'gemini'; + } +} + /** * Add API endpoint as a tool with type: 'api-endpoint' * Usage: --tool or --tool custom --model @@ -943,3 +956,133 @@ export function getFullConfigResponse(projectDir: string): { predefinedModels: { ...PREDEFINED_MODELS } }; } + +// ========== Tool Detection & Sync Functions ========== + +/** + * Sync builtin tools availability with cli-tools.json + * + * For builtin tools (gemini, qwen, codex, claude, opencode): + * - Checks actual tool availability using system PATH + * - Updates enabled status based on actual availability + * + * For non-builtin tools (cli-wrapper, api-endpoint): + * - Leaves them unchanged as they have different availability mechanisms + * + * @returns Updated config and sync results + */ +export async function syncBuiltinToolsAvailability(projectDir: string): Promise<{ + config: ClaudeCliToolsConfig; + changes: { + enabled: string[]; // Tools that were enabled + disabled: string[]; // Tools that were disabled + unchanged: string[]; // Tools that stayed the same + }; +}> { + // Import getCliToolsStatus dynamically to avoid circular dependency + const { getCliToolsStatus } = await import('./cli-executor.js'); + + // Get actual tool availability + const actualStatus = await getCliToolsStatus(); + + // Load current config + const config = loadClaudeCliTools(projectDir); + const changes = { + enabled: [] as string[], + disabled: [] as string[], + unchanged: [] as string[] + }; + + // Builtin tools that need sync + const builtinTools = ['gemini', 'qwen', 'codex', 'claude', 'opencode']; + + for (const toolName of builtinTools) { + const isAvailable = actualStatus[toolName]?.available ?? false; + const currentConfig = config.tools[toolName]; + const wasEnabled = currentConfig?.enabled ?? true; + + // Update based on actual availability + if (isAvailable && !wasEnabled) { + // Tool exists but was disabled - enable it + if (!currentConfig) { + config.tools[toolName] = { + enabled: true, + primaryModel: DEFAULT_TOOLS_CONFIG.tools[toolName]?.primaryModel || '', + secondaryModel: DEFAULT_TOOLS_CONFIG.tools[toolName]?.secondaryModel || '', + tags: [], + type: 'builtin' + }; + } else { + currentConfig.enabled = true; + } + changes.enabled.push(toolName); + } else if (!isAvailable && wasEnabled) { + // Tool doesn't exist but was enabled - disable it + if (currentConfig) { + currentConfig.enabled = false; + } + changes.disabled.push(toolName); + } else { + // No change needed + changes.unchanged.push(toolName); + } + } + + // Save updated config + saveClaudeCliTools(projectDir, config); + + console.log('[claude-cli-tools] Synced builtin tools availability:', { + enabled: changes.enabled, + disabled: changes.disabled, + unchanged: changes.unchanged + }); + + return { config, changes }; +} + +/** + * Get sync status report without actually modifying config + * + * @returns Report showing what would change if sync were run + */ +export async function getBuiltinToolsSyncReport(projectDir: string): Promise<{ + current: Record; + recommended: Record; +}> { + // Import getCliToolsStatus dynamically to avoid circular dependency + const { getCliToolsStatus } = await import('./cli-executor.js'); + + // Get actual tool availability + const actualStatus = await getCliToolsStatus(); + + // Load current config + const config = loadClaudeCliTools(projectDir); + const builtinTools = ['gemini', 'qwen', 'codex', 'claude', 'opencode']; + + const current: Record = {}; + const recommended: Record = {}; + + for (const toolName of builtinTools) { + const isAvailable = actualStatus[toolName]?.available ?? false; + const isEnabled = config.tools[toolName]?.enabled ?? true; + + current[toolName] = { + available: isAvailable, + enabled: isEnabled + }; + + if (isAvailable && !isEnabled) { + recommended[toolName] = { + shouldEnable: true, + reason: 'Tool is installed but disabled in config' + }; + } else if (!isAvailable && isEnabled) { + recommended[toolName] = { + shouldEnable: false, + reason: 'Tool is not installed but enabled in config' + }; + } + } + + return { current, recommended }; +} diff --git a/ccw/src/tools/loop-manager.ts b/ccw/src/tools/loop-manager.ts new file mode 100644 index 00000000..cd1b0769 --- /dev/null +++ b/ccw/src/tools/loop-manager.ts @@ -0,0 +1,519 @@ +/** + * Loop Manager + * CCW Loop System - Core orchestration engine + * Reference: .workflow/.scratchpad/loop-system-complete-design-20260121.md section 4.2 + */ + +import chalk from 'chalk'; +import { LoopStateManager } from './loop-state-manager.js'; +import { cliExecutorTool } from './cli-executor.js'; +import { broadcastLoopUpdate } from '../core/websocket.js'; +import type { LoopState, LoopStatus, CliStepConfig, ExecutionRecord, Task } from '../types/loop.js'; + +export class LoopManager { + private stateManager: LoopStateManager; + + constructor(workflowDir: string) { + this.stateManager = new LoopStateManager(workflowDir); + } + + /** + * Start new loop + */ + async startLoop(task: Task): Promise { + if (!task.loop_control?.enabled) { + throw new Error(`Task ${task.id} does not have loop enabled`); + } + + const loopId = this.generateLoopId(task.id); + console.log(chalk.cyan(`\n 🔄 Starting loop: ${loopId}\n`)); + + // Create initial state + const state = await this.stateManager.createState( + loopId, + task.id, + task.loop_control + ); + + // Update to running status + await this.stateManager.updateState(loopId, { status: 'running' as LoopStatus }); + + // Start execution (non-blocking) + this.runNextStep(loopId).catch(err => { + console.error(chalk.red(`\n ✗ Loop execution error: ${err}\n`)); + }); + + return loopId; + } + + /** + * Execute next step + */ + async runNextStep(loopId: string): Promise { + const state = await this.stateManager.readState(loopId); + + // Check if should terminate + if (await this.shouldTerminate(state)) { + return; + } + + // Get current step config + const stepConfig = state.cli_sequence[state.current_cli_step]; + if (!stepConfig) { + console.error(chalk.red(` ✗ Invalid step index: ${state.current_cli_step}`)); + await this.markFailed(loopId, 'Invalid step configuration'); + return; + } + + console.log(chalk.gray(` [Iteration ${state.current_iteration}] Step ${state.current_cli_step + 1}/${state.cli_sequence.length}: ${stepConfig.step_id}`)); + + try { + // Execute step + const result = await this.executeStep(state, stepConfig); + + // Update state after step + await this.updateStateAfterStep(loopId, stepConfig, result); + + // Check if iteration completed + const newState = await this.stateManager.readState(loopId); + if (newState.current_cli_step === 0) { + console.log(chalk.green(` ✓ Iteration ${newState.current_iteration - 1} completed\n`)); + + // Check success condition + if (await this.evaluateSuccessCondition(newState)) { + await this.markCompleted(loopId); + return; + } + } + + // Schedule next step (prevent stack overflow) + setImmediate(() => this.runNextStep(loopId).catch(err => { + console.error(chalk.red(`\n ✗ Next step error: ${err}\n`)); + })); + + } catch (error) { + await this.handleError(loopId, stepConfig, error as Error); + } + } + + /** + * Execute single step + */ + private async executeStep( + state: LoopState, + stepConfig: CliStepConfig + ): Promise<{ output: string; stderr: string; conversationId: string; exitCode: number; durationMs: number }> { + const startTime = Date.now(); + + // Prepare prompt (replace variables) + const prompt = stepConfig.prompt_template + ? this.replaceVariables(stepConfig.prompt_template, state.state_variables) + : ''; + + // Get resume ID + const sessionKey = `${stepConfig.tool}_${state.current_cli_step}`; + const resumeId = state.session_mapping[sessionKey]; + + // Prepare execution params + const execParams: any = { + tool: stepConfig.tool, + prompt, + mode: stepConfig.mode || 'analysis', + resume: resumeId, + stream: false + }; + + // Bash command special handling + if (stepConfig.tool === 'bash' && stepConfig.command) { + execParams.prompt = stepConfig.command; + } + + // Execute CLI tool + const result = await cliExecutorTool.execute(execParams); + + const durationMs = Date.now() - startTime; + + return { + output: result.stdout || '', + stderr: result.stderr || '', + conversationId: result.execution.id, + exitCode: result.execution.exit_code || 0, + durationMs + }; + } + + /** + * Update state after step execution + */ + private async updateStateAfterStep( + loopId: string, + stepConfig: CliStepConfig, + result: { output: string; stderr: string; conversationId: string; exitCode: number; durationMs: number } + ): Promise { + const state = await this.stateManager.readState(loopId); + + // Update session_mapping + const sessionKey = `${stepConfig.tool}_${state.current_cli_step}`; + const newSessionMapping = { + ...state.session_mapping, + [sessionKey]: result.conversationId + }; + + // Update state_variables + const newStateVariables = { + ...state.state_variables, + [`${stepConfig.step_id}_stdout`]: result.output, + [`${stepConfig.step_id}_stderr`]: result.stderr + }; + + // Add execution record + const executionRecord: ExecutionRecord = { + iteration: state.current_iteration, + step_index: state.current_cli_step, + step_id: stepConfig.step_id, + tool: stepConfig.tool, + conversation_id: result.conversationId, + exit_code: result.exitCode, + duration_ms: result.durationMs, + timestamp: new Date().toISOString() + }; + + const newExecutionHistory = [...(state.execution_history || []), executionRecord]; + + // Calculate next step + let nextStep = state.current_cli_step + 1; + let nextIteration = state.current_iteration; + + // Reset step and increment iteration if round complete + if (nextStep >= state.cli_sequence.length) { + nextStep = 0; + nextIteration += 1; + } + + // Update state + const newState = await this.stateManager.updateState(loopId, { + session_mapping: newSessionMapping, + state_variables: newStateVariables, + execution_history: newExecutionHistory, + current_cli_step: nextStep, + current_iteration: nextIteration + }); + + // Broadcast step completion with step-specific data + this.broadcastStepCompletion(loopId, stepConfig.step_id, result.exitCode, result.durationMs, result.output); + } + + /** + * Replace template variables + */ + private replaceVariables(template: string, variables: Record): string { + let result = template; + + // Replace [variable_name] format + for (const [key, value] of Object.entries(variables)) { + const regex = new RegExp(`\\[${key}\\]`, 'g'); + result = result.replace(regex, value); + } + + return result; + } + + /** + * Evaluate success condition with security constraints + * Only allows simple comparison and logical expressions + */ + private async evaluateSuccessCondition(state: LoopState): Promise { + if (!state.success_condition) { + return false; + } + + try { + // Security: Validate condition before execution + // Only allow safe characters: letters, digits, spaces, operators, parentheses, dots, quotes, underscores + const unsafePattern = /[^\w\s\.\(\)\[\]\{\}\'\"\!\=\>\<\&\|\+\-\*\/\?\:]/; + if (unsafePattern.test(state.success_condition)) { + console.error(chalk.yellow(` ⚠ Unsafe success condition contains invalid characters`)); + return false; + } + + // Block dangerous patterns + const blockedPatterns = [ + /process\./, + /require\(/, + /import\s/, + /eval\(/, + /Function\(/, + /__proto__/, + /constructor\[/ + ]; + + for (const pattern of blockedPatterns) { + if (pattern.test(state.success_condition)) { + console.error(chalk.yellow(` ⚠ Blocked dangerous pattern in success condition`)); + return false; + } + } + + // Create a minimal sandbox context with only necessary data + // Using a Proxy to restrict access to only state_variables and current_iteration + const sandbox = { + get state_variables() { + return state.state_variables; + }, + get current_iteration() { + return state.current_iteration; + } + }; + + // Create restricted context using Proxy + const restrictedContext = new Proxy(sandbox, { + has() { + return true; // Allow all property access + }, + get(target, prop) { + // Only allow access to state_variables and current_iteration + if (prop === 'state_variables' || prop === 'current_iteration') { + return target[prop]; + } + // Block access to other properties (including dangerous globals) + return undefined; + } + }); + + // Evaluate condition in restricted context + // We use the Function constructor but with a restricted scope + const conditionFn = new Function( + 'state_variables', + 'current_iteration', + `return (${state.success_condition});` + ); + + const result = conditionFn( + restrictedContext.state_variables, + restrictedContext.current_iteration + ); + + return Boolean(result); + + } catch (error) { + console.error(chalk.yellow(` ⚠ Failed to evaluate success condition: ${error instanceof Error ? error.message : error}`)); + return false; + } + } + + /** + * Check if should terminate loop + */ + private async shouldTerminate(state: LoopState): Promise { + // Completed or failed + if (state.status === 'completed' || state.status === 'failed') { + return true; + } + + // Paused + if (state.status === 'paused') { + console.log(chalk.yellow(` ⏸ Loop is paused: ${state.loop_id}`)); + return true; + } + + // Max iterations exceeded + if (state.current_iteration > state.max_iterations) { + console.log(chalk.yellow(` ⚠ Max iterations reached: ${state.max_iterations}`)); + await this.markCompleted(state.loop_id, 'Max iterations reached'); + return true; + } + + return false; + } + + /** + * Handle errors + */ + private async handleError(loopId: string, stepConfig: CliStepConfig, error: Error): Promise { + console.error(chalk.red(` ✗ Step failed: ${stepConfig.step_id}`)); + console.error(chalk.red(` ${error.message}`)); + + const state = await this.stateManager.readState(loopId); + + // Act based on error_policy + switch (state.error_policy.on_failure) { + case 'pause': + await this.pauseLoop(loopId, `Step ${stepConfig.step_id} failed: ${error.message}`); + break; + + case 'retry': + if (state.error_policy.retry_count < (state.error_policy.max_retries || 3)) { + console.log(chalk.yellow(` 🔄 Retrying... (${state.error_policy.retry_count + 1}/${state.error_policy.max_retries})`)); + await this.stateManager.updateState(loopId, { + error_policy: { + ...state.error_policy, + retry_count: state.error_policy.retry_count + 1 + } + }); + // Re-execute current step + await this.runNextStep(loopId); + } else { + await this.markFailed(loopId, `Max retries exceeded for step ${stepConfig.step_id}`); + } + break; + + case 'fail_fast': + await this.markFailed(loopId, `Step ${stepConfig.step_id} failed: ${error.message}`); + break; + } + } + + /** + * Pause loop + */ + async pauseLoop(loopId: string, reason?: string): Promise { + console.log(chalk.yellow(`\n ⏸ Pausing loop: ${loopId}`)); + if (reason) { + console.log(chalk.gray(` Reason: ${reason}`)); + } + + await this.stateManager.updateState(loopId, { + status: 'paused' as LoopStatus, + failure_reason: reason + }); + } + + /** + * Resume loop + */ + async resumeLoop(loopId: string): Promise { + console.log(chalk.cyan(`\n ▶ Resuming loop: ${loopId}\n`)); + + await this.stateManager.updateState(loopId, { + status: 'running' as LoopStatus, + error_policy: { + ...(await this.stateManager.readState(loopId)).error_policy, + retry_count: 0 + } + }); + + await this.runNextStep(loopId); + } + + /** + * Stop loop + */ + async stopLoop(loopId: string): Promise { + console.log(chalk.red(`\n ⏹ Stopping loop: ${loopId}\n`)); + + await this.stateManager.updateState(loopId, { + status: 'failed' as LoopStatus, + failure_reason: 'Manually stopped by user', + completed_at: new Date().toISOString() + }); + } + + /** + * Broadcast state update via WebSocket + */ + private broadcastStateUpdate(state: LoopState, eventType: 'LOOP_STATE_UPDATE' | 'LOOP_COMPLETED' = 'LOOP_STATE_UPDATE'): void { + try { + if (eventType === 'LOOP_STATE_UPDATE') { + broadcastLoopUpdate({ + type: 'LOOP_STATE_UPDATE', + loop_id: state.loop_id, + status: state.status as 'created' | 'running' | 'paused' | 'completed' | 'failed', + current_iteration: state.current_iteration, + current_cli_step: state.current_cli_step, + updated_at: state.updated_at + }); + } else if (eventType === 'LOOP_COMPLETED') { + broadcastLoopUpdate({ + type: 'LOOP_COMPLETED', + loop_id: state.loop_id, + final_status: state.status === 'completed' ? 'completed' : 'failed', + total_iterations: state.current_iteration, + reason: state.failure_reason + }); + } + } catch (error) { + // Silently ignore broadcast errors + } + } + + /** + * Broadcast step completion via WebSocket + */ + private broadcastStepCompletion( + loopId: string, + stepId: string, + exitCode: number, + durationMs: number, + output: string + ): void { + try { + broadcastLoopUpdate({ + type: 'LOOP_STEP_COMPLETED', + loop_id: loopId, + step_id: stepId, + exit_code: exitCode, + duration_ms: durationMs, + output: output + }); + } catch (error) { + // Silently ignore broadcast errors + } + } + + /** + * Mark as completed + */ + private async markCompleted(loopId: string, reason?: string): Promise { + console.log(chalk.green(`\n ✓ Loop completed: ${loopId}`)); + if (reason) { + console.log(chalk.gray(` ${reason}`)); + } + + const state = await this.stateManager.updateState(loopId, { + status: 'completed' as LoopStatus, + completed_at: new Date().toISOString() + }); + + // Broadcast completion + this.broadcastStateUpdate(state, 'LOOP_COMPLETED'); + } + + /** + * Mark as failed + */ + private async markFailed(loopId: string, reason: string): Promise { + console.log(chalk.red(`\n ✗ Loop failed: ${loopId}`)); + console.log(chalk.gray(` ${reason}\n`)); + + const state = await this.stateManager.updateState(loopId, { + status: 'failed' as LoopStatus, + failure_reason: reason, + completed_at: new Date().toISOString() + }); + + // Broadcast failure + this.broadcastStateUpdate(state, 'LOOP_COMPLETED'); + } + + /** + * Get loop status + */ + async getStatus(loopId: string): Promise { + return this.stateManager.readState(loopId); + } + + /** + * List all loops + */ + async listLoops(): Promise { + return this.stateManager.listStates(); + } + + /** + * Generate loop ID + */ + private generateLoopId(taskId: string): string { + const timestamp = new Date().toISOString().replace(/[-:]/g, '').split('.')[0]; + return `loop-${taskId}-${timestamp}`; + } +} diff --git a/ccw/src/tools/loop-state-manager.ts b/ccw/src/tools/loop-state-manager.ts new file mode 100644 index 00000000..63e62f4a --- /dev/null +++ b/ccw/src/tools/loop-state-manager.ts @@ -0,0 +1,173 @@ +/** + * Loop State Manager + * CCW Loop System - JSON state persistence layer + * Reference: .workflow/.scratchpad/loop-system-complete-design-20260121.md section 4.1 + */ + +import { readFile, writeFile, unlink, mkdir, copyFile } from 'fs/promises'; +import { join } from 'path'; +import { existsSync } from 'fs'; +import type { LoopState, LoopStatus, TaskLoopControl } from '../types/loop.js'; + +export class LoopStateManager { + private baseDir: string; + + constructor(workflowDir: string) { + // State files stored in .workflow/active/WFS-{session}/.loop/ + this.baseDir = join(workflowDir, '.loop'); + } + + /** + * Create new loop state + */ + async createState(loopId: string, taskId: string, config: TaskLoopControl): Promise { + await this.ensureDir(); + + const state: LoopState = { + loop_id: loopId, + task_id: taskId, + status: 'created' as LoopStatus, + current_iteration: 1, + max_iterations: config.max_iterations, + current_cli_step: 0, + cli_sequence: config.cli_sequence, + session_mapping: {}, + state_variables: {}, + success_condition: config.success_condition, + error_policy: { + on_failure: config.error_policy.on_failure, + retry_count: 0, + max_retries: config.error_policy.max_retries || 3 + }, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + execution_history: [] + }; + + await this.writeState(loopId, state); + return state; + } + + /** + * Read loop state + */ + async readState(loopId: string): Promise { + const filePath = this.getStateFilePath(loopId); + + if (!existsSync(filePath)) { + throw new Error(`Loop state not found: ${loopId}`); + } + + const content = await readFile(filePath, 'utf-8'); + return JSON.parse(content) as LoopState; + } + + /** + * Update loop state + */ + async updateState(loopId: string, updates: Partial): Promise { + const currentState = await this.readState(loopId); + + const newState: LoopState = { + ...currentState, + ...updates, + updated_at: new Date().toISOString() + }; + + await this.writeState(loopId, newState); + return newState; + } + + /** + * Delete loop state + */ + async deleteState(loopId: string): Promise { + const filePath = this.getStateFilePath(loopId); + + if (existsSync(filePath)) { + await unlink(filePath); + } + } + + /** + * List all loop states + */ + async listStates(): Promise { + if (!existsSync(this.baseDir)) { + return []; + } + + const { readdir } = await import('fs/promises'); + const files = await readdir(this.baseDir); + const stateFiles = files.filter(f => f.startsWith('loop-') && f.endsWith('.json')); + + const states: LoopState[] = []; + for (const file of stateFiles) { + const loopId = file.replace('.json', ''); + try { + const state = await this.readState(loopId); + states.push(state); + } catch (err) { + console.error(`Failed to read state ${loopId}:`, err); + } + } + + return states; + } + + /** + * Read state with recovery from backup + */ + async readStateWithRecovery(loopId: string): Promise { + try { + return await this.readState(loopId); + } catch (error) { + console.warn(`State file corrupted, attempting recovery for ${loopId}...`); + + // Try reading from backup + const backupFile = `${this.getStateFilePath(loopId)}.backup`; + if (existsSync(backupFile)) { + const content = await readFile(backupFile, 'utf-8'); + const state = JSON.parse(content) as LoopState; + // Restore from backup + await this.writeState(loopId, state); + return state; + } + + throw error; + } + } + + /** + * Get state file path + */ + getStateFilePath(loopId: string): string { + return join(this.baseDir, `${loopId}.json`); + } + + /** + * Ensure directory exists + */ + private async ensureDir(): Promise { + if (!existsSync(this.baseDir)) { + await mkdir(this.baseDir, { recursive: true }); + } + } + + /** + * Write state file with automatic backup + */ + private async writeState(loopId: string, state: LoopState): Promise { + const filePath = this.getStateFilePath(loopId); + + // Create backup if file exists + if (existsSync(filePath)) { + const backupPath = `${filePath}.backup`; + await copyFile(filePath, backupPath).catch(() => { + // Ignore backup errors + }); + } + + await writeFile(filePath, JSON.stringify(state, null, 2), 'utf-8'); + } +} diff --git a/ccw/src/types/index.ts b/ccw/src/types/index.ts index a698b8e9..a92ee898 100644 --- a/ccw/src/types/index.ts +++ b/ccw/src/types/index.ts @@ -1,3 +1,4 @@ export * from './tool.js'; export * from './session.js'; export * from './config.js'; +export * from './loop.js'; diff --git a/ccw/src/types/loop.ts b/ccw/src/types/loop.ts new file mode 100644 index 00000000..51c456dd --- /dev/null +++ b/ccw/src/types/loop.ts @@ -0,0 +1,193 @@ +/** + * Loop System Type Definitions + * CCW Loop System - JSON-based state management for multi-CLI orchestration + * Reference: .workflow/.scratchpad/loop-system-complete-design-20260121.md + */ + +/** + * Loop status enumeration + */ +export enum LoopStatus { + CREATED = 'created', + RUNNING = 'running', + PAUSED = 'paused', + COMPLETED = 'completed', + FAILED = 'failed' +} + +/** + * CLI step configuration + * Defines a single step in the CLI execution sequence + */ +export interface CliStepConfig { + /** Step unique identifier */ + step_id: string; + + /** CLI tool name */ + tool: 'bash' | 'gemini' | 'codex' | 'qwen' | string; + + /** Execution mode (for gemini/codex/claude) */ + mode?: 'analysis' | 'write' | 'review'; + + /** Bash command (when tool='bash') */ + command?: string; + + /** Prompt template with variable replacement support */ + prompt_template?: string; + + /** Step failure behavior */ + on_error?: 'continue' | 'pause' | 'fail_fast'; + + /** Custom parameters */ + custom_args?: Record; +} + +/** + * Error policy configuration + */ +export interface ErrorPolicy { + /** Failure behavior */ + on_failure: 'pause' | 'retry' | 'fail_fast'; + + /** Retry count */ + retry_count: number; + + /** Maximum retries (optional) */ + max_retries?: number; +} + +/** + * Loop state - complete definition + * Single source of truth stored in loop-state.json + */ +export interface LoopState { + /** Loop unique identifier */ + loop_id: string; + + /** Associated task ID */ + task_id: string; + + /** Current status */ + status: LoopStatus; + + /** Current iteration (1-indexed) */ + current_iteration: number; + + /** Maximum iterations */ + max_iterations: number; + + /** Current CLI step index (0-indexed) */ + current_cli_step: number; + + /** CLI execution sequence */ + cli_sequence: CliStepConfig[]; + + /** + * Session mapping table + * Key format: {tool}_{step_index} + * Value: conversation_id or execution_id + */ + session_mapping: Record; + + /** + * State variables + * Key format: {step_id}_{stdout|stderr} + * Value: corresponding output content + */ + state_variables: Record; + + /** Success condition expression (JavaScript) */ + success_condition?: string; + + /** Error policy */ + error_policy: ErrorPolicy; + + /** Creation timestamp */ + created_at: string; + + /** Last update timestamp */ + updated_at: string; + + /** Completion timestamp (if applicable) */ + completed_at?: string; + + /** Failure reason (if applicable) */ + failure_reason?: string; + + /** Execution history (optional) */ + execution_history?: ExecutionRecord[]; +} + +/** + * Execution record for history tracking + */ +export interface ExecutionRecord { + iteration: number; + step_index: number; + step_id: string; + tool: string; + conversation_id: string; + exit_code: number; + duration_ms: number; + timestamp: string; +} + +/** + * Task Loop control configuration + * Extension to Task JSON schema + */ +export interface TaskLoopControl { + /** Enable loop */ + enabled: boolean; + + /** Loop description */ + description: string; + + /** Maximum iterations */ + max_iterations: number; + + /** Success condition (JavaScript expression) */ + success_condition: string; + + /** Error policy */ + error_policy: { + on_failure: 'pause' | 'retry' | 'fail_fast'; + max_retries?: number; + }; + + /** CLI execution sequence */ + cli_sequence: CliStepConfig[]; +} + +/** + * Minimal Task interface for loop operations + * Compatible with task JSON schema + */ +export interface Task { + /** Task ID */ + id: string; + + /** Task title */ + title?: string; + + /** Task description */ + description?: string; + + /** Task status */ + status?: string; + + /** Task metadata */ + meta?: { + type?: string; + created_by?: string; + }; + + /** Task context */ + context?: { + requirements?: string[]; + acceptance?: string[]; + }; + + /** Loop control configuration */ + loop_control?: TaskLoopControl; +} diff --git a/tests/loop-comprehensive-test.js b/tests/loop-comprehensive-test.js new file mode 100644 index 00000000..ad81dbd0 --- /dev/null +++ b/tests/loop-comprehensive-test.js @@ -0,0 +1,1041 @@ +/** + * CCW Loop System - Comprehensive Test Suite + * + * Tests: + * - Multi-loop parallel execution + * - API endpoint functionality + * - WebSocket messaging + * - Security fixes (path traversal, success_condition) + * - End-to-end workflow + */ + +import { writeFileSync, readFileSync, existsSync, mkdirSync, unlinkSync, readdirSync, rmdirSync, statSync } from 'fs'; +import { join } from 'path'; + +// ANSI colors +const colors = { + reset: '\x1b[0m', + green: '\x1b[32m', + red: '\x1b[31m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', + magenta: '\x1b[35m', + bright: '\x1b[1m' +}; + +function log(color, msg) { + console.log(`${color}${msg}${colors.reset}`); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(`Assertion failed: ${message}`); + } +} + +// Test workspace +const TEST_WORKSPACE = join(process.cwd(), '.test-loop-comprehensive'); +const TEST_STATE_DIR = join(TEST_WORKSPACE, '.workflow'); +const TEST_TASK_DIR = join(TEST_WORKSPACE, '.task'); + +// Test results +const results = []; + +/** + * Setup test workspace + */ +function setupTestWorkspace() { + log(colors.blue, '🔧 Setting up test workspace...'); + + // Clean existing workspace + if (existsSync(TEST_WORKSPACE)) { + const cleanDir = (dir) => { + const files = readdirSync(dir); + files.forEach((f) => { + const fullPath = join(dir, f); + const stat = statSync(fullPath); + if (stat.isDirectory()) { + cleanDir(fullPath); + rmdirSync(fullPath); + } else { + unlinkSync(fullPath); + } + }); + }; + cleanDir(TEST_WORKSPACE); + } + + // Create directories + if (!existsSync(TEST_STATE_DIR)) { + mkdirSync(TEST_STATE_DIR, { recursive: true }); + } + if (!existsSync(TEST_TASK_DIR)) { + mkdirSync(TEST_TASK_DIR, { recursive: true }); + } + + log(colors.green, '✅ Test workspace ready'); +} + +/** + * Run a single test + */ +async function runTest(suite, name, fn) { + const start = Date.now(); + process.stdout.write(` ○ ${name}... `); + + try { + await fn(); + const duration = Date.now() - start; + results.push({ suite, name, passed: true, duration }); + log(colors.green, `✓ (${duration}ms)`); + } catch (error) { + const duration = Date.now() - start; + results.push({ suite, name, passed: false, error: error.message, duration }); + log(colors.red, `✗ ${error.message}`); + } +} + +/** + * Create a mock loop state + */ +function createLoopState(taskId, loopId) { + const id = loopId || `loop-${taskId}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const state = { + loop_id: id, + task_id: taskId, + status: 'created', + current_iteration: 0, + max_iterations: 3, + current_cli_step: 0, + cli_sequence: [ + { step_id: 'step1', tool: 'bash', command: 'echo "test"' }, + { step_id: 'step2', tool: 'gemini', mode: 'analysis', prompt_template: 'Analyze: [step1_stdout]' } + ], + session_mapping: {}, + state_variables: {}, + error_policy: { on_failure: 'pause', retry_count: 0, max_retries: 3 }, + success_condition: 'state_variables.step1_stdout && state_variables.step1_stdout.includes("test")', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() + }; + + const stateFile = join(TEST_STATE_DIR, `${id}.json`); + writeFileSync(stateFile, JSON.stringify(state, null, 2)); + return state; +} + +/** + * Create a mock task with loop_control + */ +function createTaskWithLoop(taskId) { + const task = { + id: taskId, + title: `Test Task ${taskId}`, + description: 'Test task with loop control', + status: 'pending', + loop_control: { + enabled: true, + description: 'Test loop', + max_iterations: 3, + success_condition: 'current_iteration >= 3', + error_policy: { + on_failure: 'pause', + max_retries: 3 + }, + cli_sequence: [ + { step_id: 'step1', tool: 'bash', command: 'echo "iteration"' }, + { step_id: 'step2', tool: 'gemini', mode: 'analysis', prompt_template: 'Process output' } + ] + } + }; + + const taskFile = join(TEST_TASK_DIR, `${taskId}.json`); + writeFileSync(taskFile, JSON.stringify(task, null, 2)); + return task; +} + +/** + * Read loop state + */ +function readLoopState(loopId) { + const stateFile = join(TEST_STATE_DIR, `${loopId}.json`); + return JSON.parse(readFileSync(stateFile, 'utf-8')); +} + +/** + * Update loop state + */ +function updateLoopState(loopId, updates) { + const state = readLoopState(loopId); + Object.assign(state, updates, { updated_at: new Date().toISOString() }); + const stateFile = join(TEST_STATE_DIR, `${loopId}.json`); + writeFileSync(stateFile, JSON.stringify(state, null, 2)); + return state; +} + +/** + * List all loop states + */ +function listLoopStates() { + const files = readdirSync(TEST_STATE_DIR).filter((f) => f.endsWith('.json') && f.startsWith('loop-')); + return files.map((f) => { + const content = readFileSync(join(TEST_STATE_DIR, f), 'utf-8'); + return JSON.parse(content); + }); +} + +// ============================================ +// TEST SUITE 1: MULTI-LOOP PARALLEL EXECUTION +// ============================================ + +async function testMultiLoopParallel() { + log(colors.blue, '\n📋 TEST SUITE 1: MULTI-LOOP PARALLEL EXECUTION'); + + await runTest('multi-loop', 'Create multiple loops simultaneously', async () => { + const loops = []; + for (let i = 0; i < 5; i++) { + const loop = createLoopState(`MULTI-${i}`); + loops.push(loop); + } + + assert(loops.length === 5, 'should create 5 loops'); + assert(new Set(loops.map((l) => l.loop_id)).size === 5, 'all loop IDs should be unique'); + }); + + await runTest('multi-loop', 'List all loops', () => { + const allLoops = listLoopStates(); + assert(allLoops.length === 5, 'should list all 5 loops'); + assert(allLoops.every((l) => l.loop_id.startsWith('loop-')), 'all should be valid loop IDs'); + }); + + await runTest('multi-loop', 'Update loops independently', async () => { + const allLoops = listLoopStates(); + + // Update each loop with different states + for (let i = 0; i < allLoops.length; i++) { + updateLoopState(allLoops[i].loop_id, { + status: i % 2 === 0 ? 'running' : 'paused', + current_iteration: i + 1 + }); + } + + const updated = listLoopStates(); + assert(updated.filter((l) => l.status === 'running').length === 3, '3 should be running'); + assert(updated.filter((l) => l.status === 'paused').length === 2, '2 should be paused'); + }); + + await runTest('multi-loop', 'Filter loops by status', () => { + const allLoops = listLoopStates(); + const running = allLoops.filter((l) => l.status === 'running'); + const paused = allLoops.filter((l) => l.status === 'paused'); + + assert(running.length === 3, 'should find 3 running loops'); + assert(paused.length === 2, 'should find 2 paused loops'); + }); + + await runTest('multi-loop', 'Sort loops by update time', () => { + // Add delay to ensure different timestamps + const loop = listLoopStates()[0]; + updateLoopState(loop.loop_id, { current_iteration: 99 }); + + const allLoops = listLoopStates(); + const sorted = [...allLoops].sort((a, b) => + new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() + ); + + assert(sorted[0].loop_id === loop.loop_id, 'most recently updated should be first'); + }); + + await runTest('multi-loop', 'Compute loop statistics', () => { + const allLoops = listLoopStates(); + + const byStatus = {}; + for (const loop of allLoops) { + byStatus[loop.status] = (byStatus[loop.status] || 0) + 1; + } + + assert(byStatus.running === 3, 'should count 3 running'); + assert(byStatus.paused === 2, 'should count 2 paused'); + + const activeCount = (byStatus.running || 0) + (byStatus.paused || 0); + assert(activeCount === 5, 'active count should be 5'); + }); +} + +// ============================================ +// TEST SUITE 2: STATE TRANSITIONS IN PARALLEL +// ============================================ + +async function testStateTransitions() { + log(colors.blue, '\n📋 TEST SUITE 2: PARALLEL STATE TRANSITIONS'); + + await runTest('transitions', 'Parallel pause/resume operations', async () => { + const allLoops = listLoopStates().filter((l) => l.status === 'running'); + + // Pause all running loops + allLoops.forEach((loop) => { + updateLoopState(loop.loop_id, { status: 'paused' }); + }); + + const updated = listLoopStates(); + assert(updated.filter((l) => l.status === 'paused').length === 5, 'all should be paused'); + + // Resume all + updated.forEach((loop) => { + updateLoopState(loop.loop_id, { status: 'running' }); + }); + + const resumed = listLoopStates(); + assert(resumed.filter((l) => l.status === 'running').length === 5, 'all should be running'); + }); + + await runTest('transitions', 'Independent loop progress', async () => { + const allLoops = listLoopStates(); + + // Advance each loop independently + allLoops.forEach((loop, i) => { + updateLoopState(loop.loop_id, { + current_iteration: i + 2, + current_cli_step: i % 2 + }); + }); + + const updated = listLoopStates(); + const iterations = updated.map((l) => l.current_iteration); + assert(new Set(iterations).size === 5, 'each loop should have different iteration'); + }); +} + +// ============================================ +// TEST SUITE 3: EXECUTION HISTORY +// ============================================ + +async function testExecutionHistory() { + log(colors.blue, '\n📋 TEST SUITE 3: EXECUTION HISTORY'); + + const loop = createLoopState('HISTORY-TEST'); + + await runTest('history', 'Add execution record', () => { + const record = { + iteration: 1, + step_index: 0, + step_id: 'step1', + tool: 'bash', + conversation_id: 'conv-1', + exit_code: 0, + duration_ms: 100, + timestamp: new Date().toISOString() + }; + + const state = readLoopState(loop.loop_id); + state.execution_history = [...(state.execution_history || []), record]; + + const stateFile = join(TEST_STATE_DIR, `${loop.loop_id}.json`); + writeFileSync(stateFile, JSON.stringify(state, null, 2)); + + const updated = readLoopState(loop.loop_id); + assert(updated.execution_history?.length === 1, 'should have 1 record'); + }); + + await runTest('history', 'Paginate history', () => { + const state = readLoopState(loop.loop_id); + + // Add more records + for (let i = 1; i <= 10; i++) { + state.execution_history?.push({ + iteration: i, + step_index: 0, + step_id: `step${i}`, + tool: 'bash', + conversation_id: `conv-${i}`, + exit_code: 0, + duration_ms: i * 10, + timestamp: new Date().toISOString() + }); + } + + const stateFile = join(TEST_STATE_DIR, `${loop.loop_id}.json`); + writeFileSync(stateFile, JSON.stringify(state, null, 2)); + + const updated = readLoopState(loop.loop_id); + const total = updated.execution_history?.length || 0; + + // Test pagination + const limit = 5; + const offset = 0; + const page1 = updated.execution_history?.slice(offset, offset + limit) || []; + + assert(page1.length === 5, 'page 1 should have 5 records'); + assert(total === 11, 'should have 11 total records'); + }); + + await runTest('history', 'History persists across state updates', () => { + updateLoopState(loop.loop_id, { status: 'running' }); + const updated = readLoopState(loop.loop_id); + assert(updated.execution_history?.length === 11, 'history should persist'); + }); +} + +// ============================================ +// TEST SUITE 4: STATE VARIABLES +// ============================================ + +async function testStateVariables() { + log(colors.blue, '\n📋 TEST SUITE 4: STATE VARIABLES'); + + const loop = createLoopState('VARS-TEST'); + + await runTest('variables', 'Store step output', () => { + updateLoopState(loop.loop_id, { + state_variables: { + step1_stdout: 'Tests passed: 15', + step1_stderr: '', + step1_exit_code: '0' + } + }); + + const state = readLoopState(loop.loop_id); + assert(state.state_variables.step1_stdout === 'Tests passed: 15', 'should store stdout'); + }); + + await runTest('variables', 'Accumulate variables from multiple steps', () => { + const state = readLoopState(loop.loop_id); + state.state_variables = { + ...state.state_variables, + step2_stdout: 'Analysis complete', + step2_stderr: '', + step2_exit_code: '0' + }; + + const stateFile = join(TEST_STATE_DIR, `${loop.loop_id}.json`); + writeFileSync(stateFile, JSON.stringify(state, null, 2)); + + const updated = readLoopState(loop.loop_id); + assert(Object.keys(updated.state_variables).length === 6, 'should have 6 variables'); + }); + + await runTest('variables', 'Extract logs by step_id', () => { + const state = readLoopState(loop.loop_id); + + // Group by step_id + const stepIds = new Set(); + for (const key of Object.keys(state.state_variables)) { + const match = key.match(/^(.+)_(stdout|stderr)$/); + if (match) stepIds.add(match[1]); + } + + assert(stepIds.has('step1'), 'should find step1'); + assert(stepIds.has('step2'), 'should find step2'); + }); +} + +// ============================================ +// TEST SUITE 5: SECURITY - PATH TRAVERSAL +// ============================================ + +async function testSecurityPathTraversal() { + log(colors.blue, '\n📋 TEST SUITE 5: SECURITY - PATH TRAVERSAL'); + + await runTest('security', 'isValidId rejects path separators', () => { + // Simulate the isValidId function + const isValidId = (id) => { + if (!id) return false; + if (id.includes('/') || id.includes('\\') || id === '..' || id === '.') return false; + if (id.includes('\0')) return false; + return true; + }; + + assert(!isValidId('../etc/passwd'), 'should reject ../ path'); + assert(!isValidId('..\\windows\\system32'), 'should reject ..\\ path'); + assert(!isValidId('../../'), 'should reject ../..'); + assert(!isValidId('./file'), 'should reject ./file'); + assert(!isValidId('..\u0000'), 'should reject null bytes'); + assert(isValidId('valid-loop-123'), 'should accept valid ID'); + assert(isValidId('loop-abc-123'), 'should accept valid loop ID'); + }); + + await runTest('security', 'taskId sanitization', () => { + const validTaskIds = ['TASK-001', 'loop-test', 'my_task_123']; + const invalidTaskIds = ['../task', '..\\task', 'task/../../etc', 'task\u0000']; + + // Valid IDs should pass + validTaskIds.forEach((id) => { + const hasPathChar = id.includes('/') || id.includes('\\') || id === '..' || id === '.'; + assert(!hasPathChar, `${id} should be valid`); + }); + + // Invalid IDs should be caught + invalidTaskIds.forEach((id) => { + const hasPathChar = id.includes('/') || id.includes('\\') || id === '..' || id === '.' || id.includes('\0'); + assert(hasPathChar, `${id} should be detected as invalid`); + }); + }); + + await runTest('security', 'Prevent directory traversal in file access', () => { + // Simulate file join behavior + const taskDir = TEST_TASK_DIR; + + // Normal case + const normalPath = join(taskDir, 'TASK-001.json'); + assert(normalPath.startsWith(taskDir), 'normal path should stay in directory'); + + // Path traversal attempt (would be blocked by validation) + const maliciousId = '../malicious'; + const maliciousPath = join(taskDir, `${maliciousId}.json`); + // The validation should catch this before file join + const isMalicious = maliciousId.includes('/') || maliciousId.includes('\\') || maliciousId === '..'; + assert(isMalicious, 'malicious ID should be detected'); + }); +} + +// ============================================ +// TEST SUITE 6: SECURITY - SUCCESS CONDITION +// ============================================ + +async function testSecuritySuccessCondition() { + log(colors.blue, '\n📋 TEST SUITE 6: SECURITY - SUCCESS CONDITION'); + + // Import the evaluateSuccessCondition logic (simplified for testing) + const evaluateSuccessCondition = (condition, stateVariables, currentIteration) => { + // Security checks + const unsafePattern = /[^\w\s\.\(\)\[\]\{\}\'\"\!\=\>\<\&\|\+\-\*\/\?\:]/; + if (unsafePattern.test(condition)) { + throw new Error('Unsafe success condition contains invalid characters'); + } + + const blockedPatterns = [ + /process\./, + /require\(/, + /import\s/, + /import\(/, // Block import() calls + /eval\(/, + /Function\(/, + /__proto__/, + /constructor\[/, + /["']constructor["']/ + ]; + + for (const pattern of blockedPatterns) { + if (pattern.test(condition)) { + throw new Error('Blocked dangerous pattern in success condition'); + } + } + + // Safe evaluation + try { + const conditionFn = new Function( + 'state_variables', + 'current_iteration', + `return (${condition});` + ); + return Boolean(conditionFn(stateVariables, currentIteration)); + } catch (error) { + return false; + } + }; + + await runTest('security', 'Block process.exit()', () => { + let threw = false; + let error = null; + try { + evaluateSuccessCondition('process.exit(1)', {}, 0); + } catch (e) { + threw = true; + error = e; + } + assert(threw && error && error.message.includes('Blocked'), 'should block process.exit'); + }); + + await runTest('security', 'Block require()', () => { + let threw = false; + let error = null; + try { + evaluateSuccessCondition('require("fs")', {}, 0); + } catch (e) { + threw = true; + error = e; + } + assert(threw && error && error.message.includes('Blocked'), 'should block require'); + }); + + await runTest('security', 'Block eval()', () => { + let threw = false; + let error = null; + try { + evaluateSuccessCondition('eval("malicious")', {}, 0); + } catch (e) { + threw = true; + error = e; + } + assert(threw && error && error.message.includes('Blocked'), 'should block eval'); + }); + + await runTest('security', 'Block __proto__', () => { + let threw = false; + let error = null; + try { + evaluateSuccessCondition('{}.__proto__.polluted = "yes"', {}, 0); + } catch (e) { + threw = true; + error = e; + } + assert(threw && error && error.message.includes('Blocked'), 'should block __proto__'); + }); + + await runTest('security', 'Block constructor access', () => { + let threw = false; + let error = null; + try { + evaluateSuccessCondition('this["constructor"]["return"]("code")', {}, 0); + } catch (e) { + threw = true; + error = e; + } + assert(threw && error && error.message.includes('Blocked'), 'should block constructor access'); + }); + + await runTest('security', 'Block import statement', () => { + let threw = false; + let error = null; + try { + evaluateSuccessCondition('import("fs")', {}, 0); + } catch (e) { + threw = true; + error = e; + } + assert(threw && error && error.message.includes('Blocked'), 'should block import'); + }); + + await runTest('security', 'Allow safe comparisons', () => { + const result = evaluateSuccessCondition('current_iteration >= 3', {}, 3); + assert(result === true, 'safe comparison should work'); + }); + + await runTest('security', 'Allow string operations', () => { + const vars = { output: 'Tests passed' }; + const result = evaluateSuccessCondition('state_variables.output.includes("passed")', vars, 1); + assert(result === true, 'string operations should work'); + }); + + await runTest('security', 'Allow logical AND', () => { + const vars = { test: 'pass', coverage: 90 }; + const result = evaluateSuccessCondition('state_variables.test === "pass" && state_variables.coverage > 80', vars, 1); + assert(result === true, 'logical AND should work'); + }); + + await runTest('security', 'Allow logical OR', () => { + const vars = { status: 'approved' }; + const result = evaluateSuccessCondition('state_variables.status === "approved" || state_variables.status === "LGTM"', vars, 1); + assert(result === true, 'logical OR should work'); + }); + + await runTest('security', 'Block backtick strings', () => { + let threw = false; + try { + evaluateSuccessCondition('`${process.env}`', {}, 0); + } catch (e) { + threw = true; + } + assert(threw, 'should block backtick strings'); + }); +} + +// ============================================ +// TEST SUITE 7: WEBSOCKET MESSAGE TYPES +// ============================================ + +async function testWebSocketMessages() { + log(colors.blue, '\n📋 TEST SUITE 7: WEBSOCKET MESSAGE TYPES'); + + await runTest('websocket', 'LOOP_STATE_UPDATE message structure', () => { + const message = { + type: 'LOOP_STATE_UPDATE', + loop_id: 'loop-test-123', + status: 'running', + current_iteration: 2, + current_cli_step: 1, + updated_at: new Date().toISOString() + }; + + assert(message.type === 'LOOP_STATE_UPDATE', 'type should be correct'); + assert(message.loop_id.startsWith('loop-'), 'loop_id should be valid'); + assert(['created', 'running', 'paused', 'completed', 'failed'].includes(message.status), 'status should be valid'); + }); + + await runTest('websocket', 'LOOP_STEP_COMPLETED message structure', () => { + const message = { + type: 'LOOP_STEP_COMPLETED', + loop_id: 'loop-test-123', + step_id: 'step1', + exit_code: 0, + duration_ms: 150, + output: 'Tests passed' + }; + + assert(message.type === 'LOOP_STEP_COMPLETED', 'type should be correct'); + assert(message.step_id === 'step1', 'step_id should be preserved'); + assert(message.exit_code === 0, 'exit_code should be preserved'); + }); + + await runTest('websocket', 'LOOP_COMPLETED message structure', () => { + const message = { + type: 'LOOP_COMPLETED', + loop_id: 'loop-test-123', + final_status: 'completed', + total_iterations: 5, + reason: undefined + }; + + assert(message.type === 'LOOP_COMPLETED', 'type should be correct'); + assert(message.final_status === 'completed' || message.final_status === 'failed', 'final_status should be valid'); + }); + + await runTest('websocket', 'LOOP_LOG_ENTRY message structure', () => { + const message = { + type: 'LOOP_LOG_ENTRY', + loop_id: 'loop-test-123', + step_id: 'step1', + line: 'Running tests...', + timestamp: new Date().toISOString() + }; + + assert(message.type === 'LOOP_LOG_ENTRY', 'type should be correct'); + assert(message.line, 'log line should be present'); + }); +} + +// ============================================ +// TEST SUITE 8: API RESPONSE FORMATS +// ============================================ + +async function testApiResponseFormats() { + log(colors.blue, '\n📋 TEST SUITE 8: API RESPONSE FORMATS'); + + await runTest('api', 'Success response format', () => { + const response = { + success: true, + data: { loop_id: 'loop-123', status: 'running' }, + timestamp: new Date().toISOString() + }; + + assert(response.success === true, 'success should be true'); + assert(response.data, 'data should be present'); + assert(response.timestamp, 'timestamp should be present'); + assert(!isNaN(Date.parse(response.timestamp)), 'timestamp should be valid ISO date'); + }); + + await runTest('api', 'Error response format', () => { + const response = { + success: false, + error: 'Loop not found', + status: 404 + }; + + assert(response.success === false, 'success should be false'); + assert(response.error, 'error message should be present'); + assert(response.status >= 400 && response.status < 600, 'status should be error code'); + }); + + await runTest('api', 'List response format', () => { + const response = { + success: true, + data: [{ loop_id: 'loop-1' }, { loop_id: 'loop-2' }], + total: 2, + limit: 50, + offset: 0, + hasMore: false + }; + + assert(response.success === true, 'success should be true'); + assert(Array.isArray(response.data), 'data should be array'); + assert(typeof response.total === 'number', 'total should be number'); + assert(typeof response.hasMore === 'boolean', 'hasMore should be boolean'); + }); + + await runTest('api', 'Statistics response format', () => { + const stats = { + total: 10, + by_status: { running: 3, paused: 2, completed: 4, failed: 1 }, + active_count: 5, + success_rate: 80, + avg_iterations: 2.5 + }; + + assert(stats.total === 10, 'total should be correct'); + assert(stats.by_status.running === 3, 'by_status should have counts'); + assert(stats.active_count === 5, 'active_count should sum running + paused'); + assert(stats.success_rate === 80, 'success_rate should be percentage'); + }); +} + +// ============================================ +// TEST SUITE 9: EDGE CASES +// ============================================ + +async function testEdgeCases() { + log(colors.blue, '\n📋 TEST SUITE 9: EDGE CASES'); + + await runTest('edge', 'Handle empty loop list', () => { + // Create a fresh workspace with no loops + const emptyDir = join(TEST_WORKSPACE, 'empty'); + if (!existsSync(emptyDir)) { + mkdirSync(emptyDir, { recursive: true }); + } + + const files = readdirSync(emptyDir).filter((f) => f.startsWith('loop-')); + assert(files.length === 0, 'empty workspace should have no loops'); + }); + + await runTest('edge', 'Handle loop at max iterations', () => { + const loop = createLoopState('MAX-ITER'); + updateLoopState(loop.loop_id, { + current_iteration: 3, + max_iterations: 3, + status: 'completed' + }); + + const state = readLoopState(loop.loop_id); + assert(state.current_iteration === state.max_iterations, 'should reach max'); + assert(state.status === 'completed', 'should be completed'); + }); + + await runTest('edge', 'Handle loop with no success condition', () => { + const loop = createLoopState('NO-SUCCESS'); + updateLoopState(loop.loop_id, { + success_condition: undefined, + max_iterations: 1, + current_iteration: 1 + }); + + const state = readLoopState(loop.loop_id); + assert(!state.success_condition, 'no success condition set'); + // Loop should complete based on max_iterations + assert(state.current_iteration === state.max_iterations, 'should reach max'); + }); + + await runTest('edge', 'Handle special characters in output', () => { + const specialChars = '{"key": "value", "array": [1, 2, 3]}, &\'"quotes\''; + + // Create the loop first + const loop = createLoopState('VARS-TEST'); + + updateLoopState(loop.loop_id, { + state_variables: { + special_output: specialChars + } + }); + + const state = readLoopState(loop.loop_id); + assert(state.state_variables.special_output === specialChars, 'should preserve special chars'); + }); +} + +// ============================================ +// TEST SUITE 10: END-TO-END WORKFLOW +// ============================================ + +async function testEndToEnd() { + log(colors.blue, '\n📋 TEST SUITE 10: END-TO-END WORKFLOW'); + + await runTest('e2e', 'Complete loop lifecycle', async () => { + // 1. Create task + const taskId = `E2E-TASK-${Date.now()}`; + const task = createTaskWithLoop(taskId); + assert(task.loop_control?.enabled === true, 'task should have loop enabled'); + + // 2. Start loop + const loop = createLoopState(taskId); + assert(loop.status === 'created', 'loop should start as created'); + + // 3. Transition to running + updateLoopState(loop.loop_id, { status: 'running' }); + let state = readLoopState(loop.loop_id); + assert(state.status === 'running', 'loop should be running'); + + // 4. Execute step (simulate) + updateLoopState(loop.loop_id, { + current_cli_step: 1, + state_variables: { + step1_stdout: 'test output', + step1_stderr: '', + step1_exit_code: '0' + } + }); + state = readLoopState(loop.loop_id); + assert(state.current_cli_step === 1, 'should advance to next step'); + + // 5. Complete iteration + updateLoopState(loop.loop_id, { + current_cli_step: 0, + current_iteration: 1 + }); + state = readLoopState(loop.loop_id); + assert(state.current_iteration === 1, 'should increment iteration'); + + // 6. Pause + updateLoopState(loop.loop_id, { status: 'paused' }); + state = readLoopState(loop.loop_id); + assert(state.status === 'paused', 'should be paused'); + + // 7. Resume + updateLoopState(loop.loop_id, { status: 'running' }); + state = readLoopState(loop.loop_id); + assert(state.status === 'running', 'should be running again'); + + // 8. Complete + updateLoopState(loop.loop_id, { + status: 'completed', + current_iteration: 3, + completed_at: new Date().toISOString() + }); + state = readLoopState(loop.loop_id); + assert(state.status === 'completed', 'should be completed'); + assert(state.completed_at, 'should have completion timestamp'); + }); + + await runTest('e2e', 'Failed loop with retry', async () => { + const taskId = `E2E-FAIL-${Date.now()}`; + const loop = createLoopState(taskId); + + // Simulate failure + updateLoopState(loop.loop_id, { + status: 'paused', + failure_reason: 'Step failed with exit code 1' + }); + + let state = readLoopState(loop.loop_id); + assert(state.status === 'paused', 'should pause on error'); + assert(state.failure_reason, 'should have failure reason'); + + // Simulate retry + updateLoopState(loop.loop_id, { + status: 'running', + failure_reason: undefined + }); + + state = readLoopState(loop.loop_id); + assert(state.status === 'running', 'should resume after retry'); + assert(!state.failure_reason, 'failure reason should be cleared'); + }); +} + +// ============================================ +// PRINT SUMMARY +// ============================================ + +function printSummary() { + log(colors.cyan, '\n' + '='.repeat(60)); + log(colors.cyan, '📊 COMPREHENSIVE TEST SUMMARY'); + log(colors.cyan, '='.repeat(60)); + + // Group by suite + const bySuite = {}; + for (const r of results) { + if (!bySuite[r.suite]) bySuite[r.suite] = []; + bySuite[r.suite].push(r); + } + + // Print suite summaries + for (const [suite, suiteResults] of Object.entries(bySuite)) { + const passed = suiteResults.filter((r) => r.passed).length; + const total = suiteResults.length; + const rate = ((passed / total) * 100).toFixed(0); + + const color = passed === total ? colors.green : colors.yellow; + log(color, `\n ${suite}: ${passed}/${total} (${rate}%)`); + } + + // Total stats + const total = results.length; + const passed = results.filter((r) => r.passed).length; + const failed = results.filter((r) => !r.passed).length; + const totalTime = results.reduce((sum, r) => sum + (r.duration || 0), 0); + + log(colors.cyan, '\n' + '-'.repeat(60)); + log(colors.reset, `\n Total Tests: ${total}`); + log(colors.green, ` Passed: ${passed} ✓`); + if (failed > 0) { + log(colors.red, ` Failed: ${failed} ✗`); + } + log(colors.reset, ` Success Rate: ${((passed / total) * 100).toFixed(1)}%`); + log(colors.reset, ` Total Time: ${totalTime}ms`); + + // Failed tests + if (failed > 0) { + log(colors.red, '\n❌ Failed Tests:'); + results.filter((r) => !r.passed).forEach((r) => { + log(colors.red, ` [${r.suite}] ${r.name}`); + log(colors.red, ` ${r.error}`); + }); + } + + // Performance highlights + const avgTime = totalTime / total; + const fastTests = results.filter((r) => (r.duration || 0) < 20); + const slowTests = results.filter((r) => (r.duration || 0) > 100); + + log(colors.green, `\n⚡ Average: ${avgTime.toFixed(1)}ms/test`); + if (fastTests.length > 0) { + log(colors.green, `⚡ Fast Tests (<20ms): ${fastTests.length}`); + } + if (slowTests.length > 0) { + log(colors.yellow, `🐢 Slow Tests (>100ms): ${slowTests.length}`); + } + + log(colors.cyan, '\n' + '='.repeat(60)); + + if (failed === 0) { + log(colors.bright + colors.green, '✅ ALL TESTS PASSED!'); + log(colors.green, 'The CCW Loop System comprehensive tests completed successfully.'); + } else { + log(colors.bright + colors.red, '❌ SOME TESTS FAILED'); + log(colors.red, 'Please review the failures above.'); + } + + log(colors.reset, ''); +} + +// ============================================ +// MAIN TEST RUNNER +// ============================================ + +async function runAllTests() { + log(colors.cyan, '\n' + '='.repeat(60)); + log(colors.bright + colors.cyan, '🧪 CCW LOOP SYSTEM - COMPREHENSIVE TEST SUITE'); + log(colors.cyan, '='.repeat(60)); + log(colors.cyan, 'Testing: Multi-loop, API, Security, WebSocket, E2E'); + log(colors.cyan, '='.repeat(60)); + + setupTestWorkspace(); + + try { + await testMultiLoopParallel(); + await testStateTransitions(); + await testExecutionHistory(); + await testStateVariables(); + await testSecurityPathTraversal(); + await testSecuritySuccessCondition(); + await testWebSocketMessages(); + await testApiResponseFormats(); + await testEdgeCases(); + await testEndToEnd(); + } catch (error) { + log(colors.red, `\n💥 Fatal error during test execution: ${error.message}`); + console.error(error); + } + + printSummary(); + + const failed = results.filter((r) => !r.passed).length; + return failed === 0 ? 0 : 1; +} + +// Run tests +runAllTests().then((exitCode) => { + process.exit(exitCode); +}).catch((err) => { + log(colors.red, `💥 Unhandled error: ${err.message}`); + console.error(err); + process.exit(1); +}); diff --git a/tests/run-loop-comprehensive-test.sh b/tests/run-loop-comprehensive-test.sh new file mode 100644 index 00000000..64f70d49 --- /dev/null +++ b/tests/run-loop-comprehensive-test.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# CCW Loop System - Comprehensive Test Runner + +echo "============================================" +echo "🧪 CCW LOOP SYSTEM - COMPREHENSIVE TESTS" +echo "============================================" +echo "" + +# Check if Node.js is available +if ! command -v node &> /dev/null; then + echo "❌ Error: Node.js is not installed or not in PATH" + exit 1 +fi + +# Get the project root directory +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +echo "📁 Project Root: $PROJECT_ROOT" +echo "" + +# Run the comprehensive test +node tests/loop-comprehensive-test.js "$@" + +# Exit with the test's exit code +exit $?