mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
feat: add CCW Loop System for automated iterative workflow execution
Implements a complete loop execution system with multi-loop parallel support, dashboard monitoring, and comprehensive security validation. Core features: - Loop orchestration engine (loop-manager, loop-state-manager) - Multi-loop parallel execution with independent state management - REST API endpoints for loop control (pause, resume, stop, retry) - WebSocket real-time status updates - Dashboard Loop Monitor view with live updates - Security: path traversal protection and sandboxed JavaScript evaluation Test coverage: - 42 comprehensive tests covering multi-loop, API, WebSocket, security - Security validation for success_condition injection attacks - Edge case handling and end-to-end workflow tests
This commit is contained in:
@@ -14,6 +14,7 @@ import { coreMemoryCommand } from './commands/core-memory.js';
|
|||||||
import { hookCommand } from './commands/hook.js';
|
import { hookCommand } from './commands/hook.js';
|
||||||
import { issueCommand } from './commands/issue.js';
|
import { issueCommand } from './commands/issue.js';
|
||||||
import { workflowCommand } from './commands/workflow.js';
|
import { workflowCommand } from './commands/workflow.js';
|
||||||
|
import { loopCommand } from './commands/loop.js';
|
||||||
import { readFileSync, existsSync } from 'fs';
|
import { readFileSync, existsSync } from 'fs';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { dirname, join } from 'path';
|
import { dirname, join } from 'path';
|
||||||
@@ -172,7 +173,7 @@ export function run(argv: string[]): void {
|
|||||||
.description('Unified CLI tool executor (gemini/qwen/codex/claude)')
|
.description('Unified CLI tool executor (gemini/qwen/codex/claude)')
|
||||||
.option('-p, --prompt <prompt>', 'Prompt text (alternative to positional argument)')
|
.option('-p, --prompt <prompt>', 'Prompt text (alternative to positional argument)')
|
||||||
.option('-f, --file <file>', 'Read prompt from file (best for multi-line prompts)')
|
.option('-f, --file <file>', 'Read prompt from file (best for multi-line prompts)')
|
||||||
.option('--tool <tool>', 'CLI tool to use', 'gemini')
|
.option('--tool <tool>', 'CLI tool to use (reads from cli-settings.json defaultTool if not specified)')
|
||||||
.option('--mode <mode>', 'Execution mode: analysis, write, auto', 'analysis')
|
.option('--mode <mode>', 'Execution mode: analysis, write, auto', 'analysis')
|
||||||
.option('-d, --debug', 'Enable debug logging for troubleshooting')
|
.option('-d, --debug', 'Enable debug logging for troubleshooting')
|
||||||
.option('--model <model>', 'Model override')
|
.option('--model <model>', 'Model override')
|
||||||
@@ -301,6 +302,13 @@ export function run(argv: string[]): void {
|
|||||||
.option('--queue <queue-id>', 'Target queue ID for multi-queue operations')
|
.option('--queue <queue-id>', 'Target queue ID for multi-queue operations')
|
||||||
.action((subcommand, args, options) => issueCommand(subcommand, args, options));
|
.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 <name>', 'Specify workflow session')
|
||||||
|
.action((subcommand, args, options) => loopCommand(subcommand, args, options));
|
||||||
|
|
||||||
// Workflow command - Workflow installation and management
|
// Workflow command - Workflow installation and management
|
||||||
program
|
program
|
||||||
.command('workflow [subcommand] [args...]')
|
.command('workflow [subcommand] [args...]')
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from '../tools/storage-manager.js';
|
} from '../tools/storage-manager.js';
|
||||||
import { getHistoryStore } from '../tools/cli-history-store.js';
|
import { getHistoryStore } from '../tools/cli-history-store.js';
|
||||||
import { createSpinner } from '../utils/ui.js';
|
import { createSpinner } from '../utils/ui.js';
|
||||||
|
import { loadClaudeCliSettings } from '../tools/claude-cli-tools.js';
|
||||||
|
|
||||||
// Dashboard notification settings
|
// Dashboard notification settings
|
||||||
const DASHBOARD_PORT = process.env.CCW_PORT || 3456;
|
const DASHBOARD_PORT = process.env.CCW_PORT || 3456;
|
||||||
@@ -548,7 +549,19 @@ async function statusAction(debug?: boolean): Promise<void> {
|
|||||||
* @param {Object} options - CLI options
|
* @param {Object} options - CLI options
|
||||||
*/
|
*/
|
||||||
async function execAction(positionalPrompt: string | undefined, options: CliExecOptions): Promise<void> {
|
async function execAction(positionalPrompt: string | undefined, options: CliExecOptions): Promise<void> {
|
||||||
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
|
// Enable debug mode if --debug flag is set
|
||||||
if (debug) {
|
if (debug) {
|
||||||
|
|||||||
344
ccw/src/commands/loop.ts
Normal file
344
ccw/src/commands/loop.ts
Normal file
@@ -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<Task> {
|
||||||
|
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 <name>\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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 <task-id> [--session <name>]\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 <loop-id>\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 <loop-id>\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 <loop-id>\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 <task-id> 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 <loop-id> Pause a running loop'));
|
||||||
|
console.log(chalk.gray(' resume <loop-id> Resume a paused loop'));
|
||||||
|
console.log(chalk.gray(' stop <loop-id> Stop a loop'));
|
||||||
|
console.log();
|
||||||
|
console.log(' Options:');
|
||||||
|
console.log(chalk.gray(' --session <name> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, unlinkS
|
|||||||
import { dirname, join, relative } from 'path';
|
import { dirname, join, relative } from 'path';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import type { RouteContext } from './types.js';
|
import type { RouteContext } from './types.js';
|
||||||
|
import { getDefaultTool } from '../../tools/claude-cli-tools.js';
|
||||||
|
|
||||||
interface ClaudeFile {
|
interface ClaudeFile {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -549,7 +550,8 @@ export async function handleClaudeRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
// API: CLI Sync (analyze and update CLAUDE.md using CLI tools)
|
// API: CLI Sync (analyze and update CLAUDE.md using CLI tools)
|
||||||
if (pathname === '/api/memory/claude/sync' && req.method === 'POST') {
|
if (pathname === '/api/memory/claude/sync' && req.method === 'POST') {
|
||||||
handlePostRequest(req, res, async (body: any) => {
|
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) {
|
if (!level) {
|
||||||
return { error: 'Missing level parameter', status: 400 };
|
return { error: 'Missing level parameter', status: 400 };
|
||||||
@@ -598,7 +600,7 @@ export async function handleClaudeRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
type: 'CLI_EXECUTION_STARTED',
|
type: 'CLI_EXECUTION_STARTED',
|
||||||
payload: {
|
payload: {
|
||||||
executionId: syncId,
|
executionId: syncId,
|
||||||
tool: tool === 'qwen' ? 'qwen' : 'gemini',
|
tool: resolvedTool,
|
||||||
mode: 'analysis',
|
mode: 'analysis',
|
||||||
category: 'internal',
|
category: 'internal',
|
||||||
context: 'claude-sync',
|
context: 'claude-sync',
|
||||||
@@ -629,7 +631,7 @@ export async function handleClaudeRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const result = await executeCliTool({
|
const result = await executeCliTool({
|
||||||
tool: tool === 'qwen' ? 'qwen' : 'gemini',
|
tool: resolvedTool,
|
||||||
prompt: cliPrompt,
|
prompt: cliPrompt,
|
||||||
mode: 'analysis',
|
mode: 'analysis',
|
||||||
format: 'plain',
|
format: 'plain',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '../../config/cli-settings-manager.js';
|
} from '../../config/cli-settings-manager.js';
|
||||||
import type { SaveEndpointRequest } from '../../types/cli-settings.js';
|
import type { SaveEndpointRequest } from '../../types/cli-settings.js';
|
||||||
import { validateSettings } 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
|
* Handle CLI Settings routes
|
||||||
@@ -228,5 +229,51 @@ export async function handleCliSettingsRoutes(ctx: RouteContext): Promise<boolea
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== SYNC BUILTIN TOOLS AVAILABILITY ==========
|
||||||
|
// POST /api/cli/settings/sync-tools
|
||||||
|
if (pathname === '/api/cli/settings/sync-tools' && req.method === 'POST') {
|
||||||
|
handlePostRequest(req, res, async (body: any) => {
|
||||||
|
const { initialPath } = ctx;
|
||||||
|
try {
|
||||||
|
const result = await syncBuiltinToolsAvailability(initialPath);
|
||||||
|
|
||||||
|
// Broadcast update event
|
||||||
|
broadcastToClients({
|
||||||
|
type: 'CLI_TOOLS_CONFIG_UPDATED',
|
||||||
|
payload: {
|
||||||
|
tools: result.config,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
changes: result.changes,
|
||||||
|
config: result.config
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/cli/settings/sync-report
|
||||||
|
if (pathname === '/api/cli/settings/sync-report' && req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const { initialPath } = ctx;
|
||||||
|
const report = await getBuiltinToolsSyncReport(initialPath);
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(report));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '../../../utils/uv-manager.js';
|
} from '../../../utils/uv-manager.js';
|
||||||
import type { RouteContext } from '../types.js';
|
import type { RouteContext } from '../types.js';
|
||||||
import { extractJSON } from './utils.js';
|
import { extractJSON } from './utils.js';
|
||||||
|
import { getDefaultTool } from '../../../tools/claude-cli-tools.js';
|
||||||
|
|
||||||
export async function handleCodexLensSemanticRoutes(ctx: RouteContext): Promise<boolean> {
|
export async function handleCodexLensSemanticRoutes(ctx: RouteContext): Promise<boolean> {
|
||||||
const { pathname, url, req, res, initialPath, handlePostRequest } = ctx;
|
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)
|
// API: CodexLens LLM Enhancement (run enhance command)
|
||||||
if (pathname === '/api/codexlens/enhance' && req.method === 'POST') {
|
if (pathname === '/api/codexlens/enhance' && req.method === 'POST') {
|
||||||
handlePostRequest(req, res, async (body) => {
|
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;
|
path?: unknown;
|
||||||
tool?: unknown;
|
tool?: unknown;
|
||||||
batchSize?: unknown;
|
batchSize?: unknown;
|
||||||
timeoutMs?: unknown;
|
timeoutMs?: unknown;
|
||||||
};
|
};
|
||||||
const targetPath = typeof projectPath === 'string' && projectPath.trim().length > 0 ? projectPath : initialPath;
|
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 resolvedBatchSize = typeof batchSize === 'number' ? batchSize : Number(batchSize);
|
||||||
const resolvedTimeoutMs = typeof timeoutMs === 'number' ? timeoutMs : Number(timeoutMs);
|
const resolvedTimeoutMs = typeof timeoutMs === 'number' ? timeoutMs : Number(timeoutMs);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getEmbeddingStatus, generateEmbeddings } from '../memory-embedder-bridg
|
|||||||
import { checkSemanticStatus } from '../../tools/codex-lens.js';
|
import { checkSemanticStatus } from '../../tools/codex-lens.js';
|
||||||
import { StoragePaths } from '../../config/storage-paths.js';
|
import { StoragePaths } from '../../config/storage-paths.js';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import { getDefaultTool } from '../../tools/claude-cli-tools.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route context interface
|
* Route context interface
|
||||||
@@ -173,12 +174,13 @@ export async function handleCoreMemoryRoutes(ctx: RouteContext): Promise<boolean
|
|||||||
const memoryId = pathname.replace('/api/core-memory/memories/', '').replace('/summary', '');
|
const memoryId = pathname.replace('/api/core-memory/memories/', '').replace('/summary', '');
|
||||||
|
|
||||||
handlePostRequest(req, res, async (body) => {
|
handlePostRequest(req, res, async (body) => {
|
||||||
const { tool = 'gemini', path: projectPath } = body;
|
const { tool, path: projectPath } = body;
|
||||||
const basePath = projectPath || initialPath;
|
const basePath = projectPath || initialPath;
|
||||||
|
const resolvedTool = tool || getDefaultTool(basePath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const store = getCoreMemoryStore(basePath);
|
const store = getCoreMemoryStore(basePath);
|
||||||
const summary = await store.generateSummary(memoryId, tool);
|
const summary = await store.generateSummary(memoryId, resolvedTool);
|
||||||
|
|
||||||
// Broadcast update event
|
// Broadcast update event
|
||||||
broadcastToClients({
|
broadcastToClients({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { validatePath as validateAllowedPath } from '../../utils/path-validator.js';
|
import { validatePath as validateAllowedPath } from '../../utils/path-validator.js';
|
||||||
import type { RouteContext } from './types.js';
|
import type { RouteContext } from './types.js';
|
||||||
|
import { getDefaultTool } from '../../tools/claude-cli-tools.js';
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Constants
|
// Constants
|
||||||
@@ -471,7 +472,7 @@ export async function handleFilesRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
path: targetPath,
|
path: targetPath,
|
||||||
tool = 'gemini',
|
tool,
|
||||||
strategy = 'single-layer'
|
strategy = 'single-layer'
|
||||||
} = body as { path?: unknown; tool?: unknown; strategy?: unknown };
|
} = body as { path?: unknown; tool?: unknown; strategy?: unknown };
|
||||||
|
|
||||||
@@ -481,9 +482,10 @@ export async function handleFilesRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const validatedPath = await validateAllowedPath(targetPath, { mustExist: true, allowedDirectories: [initialPath] });
|
const validatedPath = await validateAllowedPath(targetPath, { mustExist: true, allowedDirectories: [initialPath] });
|
||||||
|
const resolvedTool = typeof tool === 'string' && tool.trim().length > 0 ? tool : getDefaultTool(validatedPath);
|
||||||
return await triggerUpdateClaudeMd(
|
return await triggerUpdateClaudeMd(
|
||||||
validatedPath,
|
validatedPath,
|
||||||
typeof tool === 'string' ? tool : 'gemini',
|
resolvedTool,
|
||||||
typeof strategy === 'string' ? strategy : 'single-layer'
|
typeof strategy === 'string' ? strategy : 'single-layer'
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
386
ccw/src/core/routes/loop-routes.ts
Normal file
386
ccw/src/core/routes/loop-routes.ts
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
/**
|
||||||
|
* Loop Routes Module
|
||||||
|
* CCW Loop System - HTTP API endpoints for Dashboard
|
||||||
|
* Reference: .workflow/.scratchpad/loop-system-complete-design-20260121.md section 6.1
|
||||||
|
*
|
||||||
|
* API Endpoints:
|
||||||
|
* - GET /api/loops - List all loops
|
||||||
|
* - POST /api/loops - Start new loop from task
|
||||||
|
* - GET /api/loops/stats - Get loop statistics
|
||||||
|
* - GET /api/loops/:loopId - Get specific loop details
|
||||||
|
* - GET /api/loops/:loopId/logs - Get loop execution logs
|
||||||
|
* - GET /api/loops/:loopId/history - Get execution history (paginated)
|
||||||
|
* - POST /api/loops/:loopId/pause - Pause loop
|
||||||
|
* - POST /api/loops/:loopId/resume - Resume loop
|
||||||
|
* - POST /api/loops/:loopId/stop - Stop loop
|
||||||
|
* - POST /api/loops/:loopId/retry - Retry failed step
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { join } from 'path';
|
||||||
|
import { LoopManager } from '../../tools/loop-manager.js';
|
||||||
|
import type { RouteContext } from './types.js';
|
||||||
|
import type { LoopState } from '../../types/loop.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle loop routes
|
||||||
|
* @returns true if route was handled, false otherwise
|
||||||
|
*/
|
||||||
|
export async function handleLoopRoutes(ctx: RouteContext): Promise<boolean> {
|
||||||
|
const { pathname, req, res, initialPath, handlePostRequest, url } = ctx;
|
||||||
|
|
||||||
|
// Get workflow directory from initialPath
|
||||||
|
const workflowDir = initialPath || process.cwd();
|
||||||
|
const loopManager = new LoopManager(workflowDir);
|
||||||
|
|
||||||
|
// ==== EXACT PATH ROUTES (must come first) ====
|
||||||
|
|
||||||
|
// GET /api/loops/stats - Get loop statistics
|
||||||
|
if (pathname === '/api/loops/stats' && req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const loops = await loopManager.listLoops();
|
||||||
|
const stats = computeLoopStats(loops);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, data: stats, timestamp: new Date().toISOString() }));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/loops - Start new loop from task
|
||||||
|
if (pathname === '/api/loops' && req.method === 'POST') {
|
||||||
|
handlePostRequest(req, res, async (body) => {
|
||||||
|
const { taskId } = body as { taskId?: string };
|
||||||
|
|
||||||
|
if (!taskId) {
|
||||||
|
return { success: false, error: 'taskId is required', status: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read task config from .task directory
|
||||||
|
const taskPath = join(workflowDir, '.task', taskId + '.json');
|
||||||
|
const { readFile } = await import('fs/promises');
|
||||||
|
const { existsSync } = await import('fs');
|
||||||
|
|
||||||
|
if (!existsSync(taskPath)) {
|
||||||
|
return { success: false, error: 'Task not found: ' + taskId, status: 404 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskContent = await readFile(taskPath, 'utf-8');
|
||||||
|
const task = JSON.parse(taskContent);
|
||||||
|
|
||||||
|
if (!task.loop_control?.enabled) {
|
||||||
|
return { success: false, error: 'Task ' + taskId + ' does not have loop enabled', status: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const loopId = await loopManager.startLoop(task);
|
||||||
|
|
||||||
|
return { success: true, data: { loopId, taskId } };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message, status: 500 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/loops - List all loops
|
||||||
|
if (pathname === '/api/loops' && req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const loops = await loopManager.listLoops();
|
||||||
|
|
||||||
|
// Parse query params for filtering
|
||||||
|
const searchParams = url?.searchParams;
|
||||||
|
let filteredLoops = loops;
|
||||||
|
|
||||||
|
// Filter by status
|
||||||
|
const statusFilter = searchParams?.get('status');
|
||||||
|
if (statusFilter && statusFilter !== 'all') {
|
||||||
|
filteredLoops = filteredLoops.filter(l => l.status === statusFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by updated_at (most recent first)
|
||||||
|
filteredLoops.sort((a, b) =>
|
||||||
|
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
data: filteredLoops,
|
||||||
|
total: filteredLoops.length,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==== NESTED PATH ROUTES (more specific patterns first) ====
|
||||||
|
|
||||||
|
// GET /api/loops/:loopId/logs - Get loop execution logs
|
||||||
|
if (pathname.match(/\/api\/loops\/[^/]+\/logs$/) && req.method === 'GET') {
|
||||||
|
const loopId = pathname.split('/').slice(-2)[0];
|
||||||
|
if (!loopId || !isValidId(loopId)) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = await loopManager.getStatus(loopId);
|
||||||
|
|
||||||
|
// Extract logs from state_variables
|
||||||
|
const logs: Array<{
|
||||||
|
step_id: string;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
timestamp?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Group by step_id
|
||||||
|
const stepIds = new Set<string>();
|
||||||
|
for (const key of Object.keys(state.state_variables || {})) {
|
||||||
|
const match = key.match(/^(.+)_(stdout|stderr)$/);
|
||||||
|
if (match) stepIds.add(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const stepId of stepIds) {
|
||||||
|
logs.push({
|
||||||
|
step_id: stepId,
|
||||||
|
stdout: state.state_variables?.[`${stepId}_stdout`] || '',
|
||||||
|
stderr: state.state_variables?.[`${stepId}_stderr`] || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
loop_id: loopId,
|
||||||
|
logs,
|
||||||
|
total: logs.length
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/loops/:loopId/history - Get execution history (paginated)
|
||||||
|
if (pathname.match(/\/api\/loops\/[^/]+\/history$/) && req.method === 'GET') {
|
||||||
|
const loopId = pathname.split('/').slice(-2)[0];
|
||||||
|
if (!loopId || !isValidId(loopId)) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = await loopManager.getStatus(loopId);
|
||||||
|
const history = state.execution_history || [];
|
||||||
|
|
||||||
|
// Parse pagination params
|
||||||
|
const searchParams = url?.searchParams;
|
||||||
|
const limit = parseInt(searchParams?.get('limit') || '50', 10);
|
||||||
|
const offset = parseInt(searchParams?.get('offset') || '0', 10);
|
||||||
|
|
||||||
|
// Slice history for pagination
|
||||||
|
const paginatedHistory = history.slice(offset, offset + limit);
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
data: paginatedHistory,
|
||||||
|
total: history.length,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
hasMore: offset + limit < history.length
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/loops/:loopId/pause - Pause loop
|
||||||
|
if (pathname.match(/\/api\/loops\/[^/]+\/pause$/) && req.method === 'POST') {
|
||||||
|
const loopId = pathname.split('/').slice(-2)[0];
|
||||||
|
if (!loopId || !isValidId(loopId)) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loopManager.pauseLoop(loopId);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, message: 'Loop paused' }));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/loops/:loopId/resume - Resume loop
|
||||||
|
if (pathname.match(/\/api\/loops\/[^/]+\/resume$/) && req.method === 'POST') {
|
||||||
|
const loopId = pathname.split('/').slice(-2)[0];
|
||||||
|
if (!loopId || !isValidId(loopId)) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loopManager.resumeLoop(loopId);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, message: 'Loop resumed' }));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/loops/:loopId/stop - Stop loop
|
||||||
|
if (pathname.match(/\/api\/loops\/[^/]+\/stop$/) && req.method === 'POST') {
|
||||||
|
const loopId = pathname.split('/').slice(-2)[0];
|
||||||
|
if (!loopId || !isValidId(loopId)) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loopManager.stopLoop(loopId);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, message: 'Loop stopped' }));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/loops/:loopId/retry - Retry failed step
|
||||||
|
if (pathname.match(/\/api\/loops\/[^/]+\/retry$/) && req.method === 'POST') {
|
||||||
|
const loopId = pathname.split('/').slice(-2)[0];
|
||||||
|
if (!loopId || !isValidId(loopId)) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = await loopManager.getStatus(loopId);
|
||||||
|
|
||||||
|
// Can only retry if paused or failed
|
||||||
|
if (!['paused', 'failed'].includes(state.status)) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: 'Can only retry paused or failed loops'
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume the loop (retry from current step)
|
||||||
|
await loopManager.resumeLoop(loopId);
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, message: 'Loop retry initiated' }));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==== SINGLE PARAM ROUTES (most generic, must come last) ====
|
||||||
|
|
||||||
|
// GET /api/loops/:loopId - Get specific loop details
|
||||||
|
if (pathname.match(/^\/api\/loops\/[^/]+$/) && req.method === 'GET') {
|
||||||
|
const loopId = pathname.split('/').pop();
|
||||||
|
if (!loopId || !isValidId(loopId)) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = await loopManager.getStatus(loopId);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, data: state }));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute statistics from loop list
|
||||||
|
*/
|
||||||
|
function computeLoopStats(loops: LoopState[]): {
|
||||||
|
total: number;
|
||||||
|
by_status: Record<string, number>;
|
||||||
|
active_count: number;
|
||||||
|
success_rate: number;
|
||||||
|
avg_iterations: number;
|
||||||
|
} {
|
||||||
|
const byStatus: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const loop of loops) {
|
||||||
|
byStatus[loop.status] = (byStatus[loop.status] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedCount = byStatus['completed'] || 0;
|
||||||
|
const failedCount = byStatus['failed'] || 0;
|
||||||
|
const totalFinished = completedCount + failedCount;
|
||||||
|
|
||||||
|
const successRate = totalFinished > 0
|
||||||
|
? Math.round((completedCount / totalFinished) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const avgIterations = loops.length > 0
|
||||||
|
? Math.round(loops.reduce((sum, l) => sum + l.current_iteration, 0) / loops.length * 10) / 10
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: loops.length,
|
||||||
|
by_status: byStatus,
|
||||||
|
active_count: (byStatus['running'] || 0) + (byStatus['paused'] || 0),
|
||||||
|
success_rate: successRate,
|
||||||
|
avg_iterations: avgIterations
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize ID parameter to prevent path traversal attacks
|
||||||
|
* @returns true if valid, false if invalid
|
||||||
|
*/
|
||||||
|
function isValidId(id: string): boolean {
|
||||||
|
if (!id) return false;
|
||||||
|
// Block path traversal attempts and null bytes
|
||||||
|
if (id.includes('/') || id.includes('\\') || id === '..' || id === '.') return false;
|
||||||
|
if (id.includes('\0')) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { homedir } from 'os';
|
|||||||
import { getMemoryStore } from '../memory-store.js';
|
import { getMemoryStore } from '../memory-store.js';
|
||||||
import { executeCliTool } from '../../tools/cli-executor.js';
|
import { executeCliTool } from '../../tools/cli-executor.js';
|
||||||
import { SmartContentFormatter } from '../../tools/cli-output-converter.js';
|
import { SmartContentFormatter } from '../../tools/cli-output-converter.js';
|
||||||
|
import { getDefaultTool } from '../../tools/claude-cli-tools.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route context interface
|
* Route context interface
|
||||||
@@ -340,7 +341,7 @@ export async function handleMemoryRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
if (pathname === '/api/memory/insights/analyze' && req.method === 'POST') {
|
if (pathname === '/api/memory/insights/analyze' && req.method === 'POST') {
|
||||||
handlePostRequest(req, res, async (body: any) => {
|
handlePostRequest(req, res, async (body: any) => {
|
||||||
const projectPath = body.path || initialPath;
|
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 prompts = body.prompts || [];
|
||||||
const lang = body.lang || 'en'; // Language preference
|
const lang = body.lang || 'en'; // Language preference
|
||||||
|
|
||||||
|
|||||||
319
ccw/src/core/routes/task-routes.ts
Normal file
319
ccw/src/core/routes/task-routes.ts
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
/**
|
||||||
|
* Task Routes Module
|
||||||
|
* CCW Loop System - HTTP API endpoints for Task management
|
||||||
|
* Reference: .workflow/.scratchpad/loop-system-complete-design-20260121.md section 6.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { join } from 'path';
|
||||||
|
import { readdir, readFile, writeFile } from 'fs/promises';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import type { RouteContext } from './types.js';
|
||||||
|
import type { Task } from '../../types/loop.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle task routes
|
||||||
|
* @returns true if route was handled, false otherwise
|
||||||
|
*/
|
||||||
|
export async function handleTaskRoutes(ctx: RouteContext): Promise<boolean> {
|
||||||
|
const { pathname, req, res, initialPath, handlePostRequest } = ctx;
|
||||||
|
|
||||||
|
// Get workflow directory from initialPath
|
||||||
|
const workflowDir = initialPath || process.cwd();
|
||||||
|
const taskDir = join(workflowDir, '.task');
|
||||||
|
|
||||||
|
// GET /api/tasks - List all tasks
|
||||||
|
if (pathname === '/api/tasks' && req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
// Ensure task directory exists
|
||||||
|
if (!existsSync(taskDir)) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, data: [], total: 0 }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read all task files
|
||||||
|
const files = await readdir(taskDir);
|
||||||
|
const taskFiles = files.filter(f => f.endsWith('.json'));
|
||||||
|
|
||||||
|
const tasks: Task[] = [];
|
||||||
|
for (const file of taskFiles) {
|
||||||
|
try {
|
||||||
|
const filePath = join(taskDir, file);
|
||||||
|
const content = await readFile(filePath, 'utf-8');
|
||||||
|
const task = JSON.parse(content) as Task;
|
||||||
|
tasks.push(task);
|
||||||
|
} catch (error) {
|
||||||
|
// Skip invalid task files
|
||||||
|
console.error('Failed to read task file ' + file + ':', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse query parameters
|
||||||
|
const url = new URL(req.url || '', `http://localhost`);
|
||||||
|
const loopOnly = url.searchParams.get('loop_only') === 'true';
|
||||||
|
const filterStatus = url.searchParams.get('filter'); // active | completed
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
let filteredTasks = tasks;
|
||||||
|
|
||||||
|
// Filter by loop_control.enabled
|
||||||
|
if (loopOnly) {
|
||||||
|
filteredTasks = filteredTasks.filter(t => t.loop_control?.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by status
|
||||||
|
if (filterStatus) {
|
||||||
|
filteredTasks = filteredTasks.filter(t => t.status === filterStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
data: filteredTasks,
|
||||||
|
total: filteredTasks.length,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: (error as Error).message
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/tasks - Create new task
|
||||||
|
if (pathname === '/api/tasks' && req.method === 'POST') {
|
||||||
|
handlePostRequest(req, res, async (body) => {
|
||||||
|
const task = body as Partial<Task>;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!task.id) {
|
||||||
|
return { success: false, error: 'Task ID is required', status: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize taskId to prevent path traversal
|
||||||
|
if (task.id.includes('/') || task.id.includes('\\') || task.id === '..' || task.id === '.') {
|
||||||
|
return { success: false, error: 'Invalid task ID format', status: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!task.loop_control) {
|
||||||
|
return { success: false, error: 'loop_control is required', status: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!task.loop_control.enabled) {
|
||||||
|
return { success: false, error: 'loop_control.enabled must be true', status: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!task.loop_control.cli_sequence || task.loop_control.cli_sequence.length === 0) {
|
||||||
|
return { success: false, error: 'cli_sequence must contain at least one step', status: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ensure task directory exists
|
||||||
|
const { mkdir } = await import('fs/promises');
|
||||||
|
if (!existsSync(taskDir)) {
|
||||||
|
await mkdir(taskDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if task already exists
|
||||||
|
const taskPath = join(taskDir, task.id + '.json');
|
||||||
|
if (existsSync(taskPath)) {
|
||||||
|
return { success: false, error: 'Task already exists: ' + task.id, status: 409 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build complete task object
|
||||||
|
const fullTask: Task = {
|
||||||
|
id: task.id,
|
||||||
|
title: task.title || task.id,
|
||||||
|
description: task.description || task.loop_control?.description || '',
|
||||||
|
status: task.status || 'active',
|
||||||
|
meta: task.meta,
|
||||||
|
context: task.context,
|
||||||
|
loop_control: task.loop_control
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write task file
|
||||||
|
await writeFile(taskPath, JSON.stringify(fullTask, null, 2), 'utf-8');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
task: fullTask,
|
||||||
|
path: taskPath
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message, status: 500 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/tasks/validate - Validate task loop_control configuration
|
||||||
|
if (pathname === '/api/tasks/validate' && req.method === 'POST') {
|
||||||
|
handlePostRequest(req, res, async (body) => {
|
||||||
|
const task = body as Partial<Task>;
|
||||||
|
const errors: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// Validate loop_control
|
||||||
|
if (!task.loop_control) {
|
||||||
|
errors.push('loop_control is required');
|
||||||
|
} else {
|
||||||
|
// Check enabled flag
|
||||||
|
if (typeof task.loop_control.enabled !== 'boolean') {
|
||||||
|
errors.push('loop_control.enabled must be a boolean');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cli_sequence
|
||||||
|
if (!task.loop_control.cli_sequence || !Array.isArray(task.loop_control.cli_sequence)) {
|
||||||
|
errors.push('loop_control.cli_sequence must be an array');
|
||||||
|
} else if (task.loop_control.cli_sequence.length === 0) {
|
||||||
|
errors.push('loop_control.cli_sequence must contain at least one step');
|
||||||
|
} else {
|
||||||
|
// Validate each step
|
||||||
|
task.loop_control.cli_sequence.forEach((step, index) => {
|
||||||
|
if (!step.step_id) {
|
||||||
|
errors.push(`Step ${index + 1}: step_id is required`);
|
||||||
|
}
|
||||||
|
if (!step.tool) {
|
||||||
|
errors.push(`Step ${index + 1}: tool is required`);
|
||||||
|
} else if (!['gemini', 'qwen', 'codex', 'claude', 'bash'].includes(step.tool)) {
|
||||||
|
warnings.push(`Step ${index + 1}: unknown tool '${step.tool}'`);
|
||||||
|
}
|
||||||
|
if (!step.prompt_template && step.tool !== 'bash') {
|
||||||
|
errors.push(`Step ${index + 1}: prompt_template is required for non-bash steps`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check max_iterations
|
||||||
|
if (task.loop_control.max_iterations !== undefined) {
|
||||||
|
if (typeof task.loop_control.max_iterations !== 'number' || task.loop_control.max_iterations < 1) {
|
||||||
|
errors.push('loop_control.max_iterations must be a positive number');
|
||||||
|
}
|
||||||
|
if (task.loop_control.max_iterations > 100) {
|
||||||
|
warnings.push('max_iterations > 100 may cause long execution times');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return validation result
|
||||||
|
const isValid = errors.length === 0;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
valid: isValid,
|
||||||
|
errors,
|
||||||
|
warnings
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/tasks/:taskId - Update existing task
|
||||||
|
if (pathname.match(/^\/api\/tasks\/[^/]+$/) && req.method === 'PUT') {
|
||||||
|
const taskId = pathname.split('/').pop();
|
||||||
|
if (!taskId) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: 'Task ID required' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize taskId to prevent path traversal
|
||||||
|
if (taskId.includes('/') || taskId.includes('\\') || taskId === '..' || taskId === '.') {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: 'Invalid task ID format' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePostRequest(req, res, async (body) => {
|
||||||
|
const updates = body as Partial<Task>;
|
||||||
|
const taskPath = join(taskDir, taskId + '.json');
|
||||||
|
|
||||||
|
// Check if task exists
|
||||||
|
if (!existsSync(taskPath)) {
|
||||||
|
return { success: false, error: 'Task not found: ' + taskId, status: 404 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read existing task
|
||||||
|
const existingContent = await readFile(taskPath, 'utf-8');
|
||||||
|
const existingTask = JSON.parse(existingContent) as Task;
|
||||||
|
|
||||||
|
// Merge updates (preserve id)
|
||||||
|
const updatedTask: Task = {
|
||||||
|
...existingTask,
|
||||||
|
...updates,
|
||||||
|
id: existingTask.id // Prevent id change
|
||||||
|
};
|
||||||
|
|
||||||
|
// If loop_control is being updated, merge it properly
|
||||||
|
if (updates.loop_control) {
|
||||||
|
updatedTask.loop_control = {
|
||||||
|
...existingTask.loop_control,
|
||||||
|
...updates.loop_control
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write updated task
|
||||||
|
await writeFile(taskPath, JSON.stringify(updatedTask, null, 2), 'utf-8');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
task: updatedTask,
|
||||||
|
path: taskPath
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message, status: 500 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/tasks/:taskId - Get specific task
|
||||||
|
if (pathname.match(/^\/api\/tasks\/[^/]+$/) && req.method === 'GET') {
|
||||||
|
const taskId = pathname.split('/').pop();
|
||||||
|
if (!taskId) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: 'Task ID required' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize taskId to prevent path traversal
|
||||||
|
if (taskId.includes('/') || taskId.includes('\\') || taskId === '..' || taskId === '.') {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: 'Invalid task ID format' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const taskPath = join(taskDir, taskId + '.json');
|
||||||
|
|
||||||
|
if (!existsSync(taskPath)) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: 'Task not found' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await readFile(taskPath, 'utf-8');
|
||||||
|
const task = JSON.parse(content) as Task;
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, data: task }));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
312
ccw/src/core/routes/test-loop-routes.ts
Normal file
312
ccw/src/core/routes/test-loop-routes.ts
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
/**
|
||||||
|
* Test Loop Routes - Mock CLI endpoints for Loop system testing
|
||||||
|
* Provides simulated CLI tool responses for testing Loop workflows
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock execution history storage
|
||||||
|
* In production, this would be actual CLI execution results
|
||||||
|
*/
|
||||||
|
const mockExecutionStore = new Map<string, any[]>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock CLI tool responses
|
||||||
|
*/
|
||||||
|
const mockResponses = {
|
||||||
|
// Bash mock responses
|
||||||
|
bash: {
|
||||||
|
npm_test_pass: {
|
||||||
|
exitCode: 0,
|
||||||
|
stdout: 'Test Suites: 1 passed, 1 total\nTests: 15 passed, 15 total\nSnapshots: 0 total\nTime: 2.345 s\nAll tests passed!',
|
||||||
|
stderr: ''
|
||||||
|
},
|
||||||
|
npm_test_fail: {
|
||||||
|
exitCode: 1,
|
||||||
|
stdout: 'Test Suites: 1 failed, 1 total\nTests: 14 passed, 1 failed, 15 total',
|
||||||
|
stderr: 'FAIL src/utils/validation.test.js\n \u251c Validation should reject invalid input\n Error: expect(received).toBe(true)\n Received: false\n at validation.test.js:42:18'
|
||||||
|
},
|
||||||
|
npm_lint: {
|
||||||
|
exitCode: 0,
|
||||||
|
stdout: 'Linting complete!\n0 errors, 2 warnings',
|
||||||
|
stderr: ''
|
||||||
|
},
|
||||||
|
npm_benchmark_slow: {
|
||||||
|
exitCode: 0,
|
||||||
|
stdout: 'Running benchmark...\nOperation: 10000 ops\nAverage: 125ms\nMin: 110ms\nMax: 145ms',
|
||||||
|
stderr: ''
|
||||||
|
},
|
||||||
|
npm_benchmark_fast: {
|
||||||
|
exitCode: 0,
|
||||||
|
stdout: 'Running benchmark...\nOperation: 10000 ops\nAverage: 35ms\nMin: 28ms\nMax: 42ms',
|
||||||
|
stderr: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Gemini mock responses
|
||||||
|
gemini: {
|
||||||
|
analyze_failure: `## Root Cause Analysis
|
||||||
|
|
||||||
|
### Failed Test
|
||||||
|
- Test: Validation should reject invalid input
|
||||||
|
- File: src/utils/validation.test.js:42
|
||||||
|
|
||||||
|
### Error Analysis
|
||||||
|
The validation function is not properly checking for empty strings. The test expects \`true\` for validation result, but receives \`false\`.
|
||||||
|
|
||||||
|
### Affected Files
|
||||||
|
- src/utils/validation.js
|
||||||
|
|
||||||
|
### Fix Suggestion
|
||||||
|
Update the validation function to handle empty string case:
|
||||||
|
\`\`\`javascript
|
||||||
|
function validateInput(input) {
|
||||||
|
if (!input || input.trim() === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// ... rest of validation
|
||||||
|
}
|
||||||
|
\`\`\``,
|
||||||
|
analyze_performance: `## Performance Analysis
|
||||||
|
|
||||||
|
### Current Performance
|
||||||
|
- Average: 125ms per operation
|
||||||
|
- Target: < 50ms
|
||||||
|
|
||||||
|
### Bottleneck Identified
|
||||||
|
The main loop in src/processor.js has O(n²) complexity due to nested array operations.
|
||||||
|
|
||||||
|
### Optimization Suggestion
|
||||||
|
Replace nested forEach with Map-based lookup to achieve O(n) complexity.`,
|
||||||
|
code_review: `## Code Review Summary
|
||||||
|
|
||||||
|
### Overall Assessment: LGTM
|
||||||
|
|
||||||
|
### Findings
|
||||||
|
- Code structure is clear
|
||||||
|
- Error handling is appropriate
|
||||||
|
- Comments are sufficient
|
||||||
|
|
||||||
|
### Score: 9/10`
|
||||||
|
},
|
||||||
|
// Codex mock responses
|
||||||
|
codex: {
|
||||||
|
fix_validation: `Modified files:
|
||||||
|
- src/utils/validation.js
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
Added empty string check in validateInput function:
|
||||||
|
\`\`\`javascript
|
||||||
|
function validateInput(input) {
|
||||||
|
// Check for null, undefined, or empty string
|
||||||
|
if (!input || typeof input !== 'string' || input.trim() === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// ... existing validation logic
|
||||||
|
}
|
||||||
|
\`\`\``,
|
||||||
|
optimize_performance: `Modified files:
|
||||||
|
- src/processor.js
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
Replaced nested forEach with Map-based lookup:
|
||||||
|
\`\`\`javascript
|
||||||
|
// Before: O(n²)
|
||||||
|
items.forEach(item => {
|
||||||
|
otherItems.forEach(other => {
|
||||||
|
if (item.id === other.id) { /* ... */ }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// After: O(n)
|
||||||
|
const lookup = new Map(otherItems.map(o => [o.id, o]));
|
||||||
|
items.forEach(item => {
|
||||||
|
const other = lookup.get(item.id);
|
||||||
|
if (other) { /* ... */ }
|
||||||
|
});
|
||||||
|
\`\`\``,
|
||||||
|
add_tests: `Modified files:
|
||||||
|
- tests/utils/math.test.js
|
||||||
|
|
||||||
|
Added new test cases:
|
||||||
|
- testAddition()
|
||||||
|
- testSubtraction()
|
||||||
|
- testMultiplication()
|
||||||
|
- testDivision()`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle test loop routes
|
||||||
|
* Provides mock CLI endpoints for testing Loop workflows
|
||||||
|
*/
|
||||||
|
export async function handleTestLoopRoutes(ctx: RouteContext): Promise<boolean> {
|
||||||
|
const { pathname, req, res, initialPath, handlePostRequest } = ctx;
|
||||||
|
const workflowDir = initialPath || process.cwd();
|
||||||
|
|
||||||
|
// Only handle test routes in test mode
|
||||||
|
if (!pathname.startsWith('/api/test/loop')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/test/loop/mock/reset - Reset mock execution store
|
||||||
|
if (pathname === '/api/test/loop/mock/reset' && req.method === 'POST') {
|
||||||
|
mockExecutionStore.clear();
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, message: 'Mock execution store reset' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/test/loop/mock/history - Get mock execution history
|
||||||
|
if (pathname === '/api/test/loop/mock/history' && req.method === 'GET') {
|
||||||
|
const history = Array.from(mockExecutionStore.entries()).map(([loopId, records]) => ({
|
||||||
|
loopId,
|
||||||
|
records
|
||||||
|
}));
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, data: history }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/test/loop/mock/cli/execute - Mock CLI execution
|
||||||
|
if (pathname === '/api/test/loop/mock/cli/execute' && req.method === 'POST') {
|
||||||
|
handlePostRequest(req, res, async (body) => {
|
||||||
|
const { loopId, stepId, tool, command, prompt } = body as {
|
||||||
|
loopId?: string;
|
||||||
|
stepId?: string;
|
||||||
|
tool?: string;
|
||||||
|
command?: string;
|
||||||
|
prompt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!loopId || !stepId || !tool) {
|
||||||
|
return { success: false, error: 'loopId, stepId, and tool are required', status: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate execution delay
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Get mock response based on tool and command/prompt
|
||||||
|
let mockResult: any;
|
||||||
|
|
||||||
|
if (tool === 'bash') {
|
||||||
|
if (command?.includes('test')) {
|
||||||
|
// Determine pass/fail based on iteration
|
||||||
|
const history = mockExecutionStore.get(loopId) || [];
|
||||||
|
const iterationCount = history.filter(r => r.stepId === 'run_tests').length;
|
||||||
|
mockResult = iterationCount >= 2 ? mockResponses.bash.npm_test_pass : mockResponses.bash.npm_test_fail;
|
||||||
|
} else if (command?.includes('lint')) {
|
||||||
|
mockResult = mockResponses.bash.npm_lint;
|
||||||
|
} else if (command?.includes('benchmark')) {
|
||||||
|
const history = mockExecutionStore.get(loopId) || [];
|
||||||
|
const iterationCount = history.filter(r => r.stepId === 'run_benchmark').length;
|
||||||
|
mockResult = iterationCount >= 3 ? mockResponses.bash.npm_benchmark_fast : mockResponses.bash.npm_benchmark_slow;
|
||||||
|
} else {
|
||||||
|
mockResult = { exitCode: 0, stdout: 'Command executed', stderr: '' };
|
||||||
|
}
|
||||||
|
} else if (tool === 'gemini') {
|
||||||
|
if (prompt?.includes('failure')) {
|
||||||
|
mockResult = { exitCode: 0, stdout: mockResponses.gemini.analyze_failure, stderr: '' };
|
||||||
|
} else if (prompt?.includes('performance')) {
|
||||||
|
mockResult = { exitCode: 0, stdout: mockResponses.gemini.analyze_performance, stderr: '' };
|
||||||
|
} else if (prompt?.includes('review')) {
|
||||||
|
mockResult = { exitCode: 0, stdout: mockResponses.gemini.code_review, stderr: '' };
|
||||||
|
} else {
|
||||||
|
mockResult = { exitCode: 0, stdout: 'Analysis complete', stderr: '' };
|
||||||
|
}
|
||||||
|
} else if (tool === 'codex') {
|
||||||
|
if (prompt?.includes('validation') || prompt?.includes('fix')) {
|
||||||
|
mockResult = { exitCode: 0, stdout: mockResponses.codex.fix_validation, stderr: '' };
|
||||||
|
} else if (prompt?.includes('performance') || prompt?.includes('optimize')) {
|
||||||
|
mockResult = { exitCode: 0, stdout: mockResponses.codex.optimize_performance, stderr: '' };
|
||||||
|
} else if (prompt?.includes('test')) {
|
||||||
|
mockResult = { exitCode: 0, stdout: mockResponses.codex.add_tests, stderr: '' };
|
||||||
|
} else {
|
||||||
|
mockResult = { exitCode: 0, stdout: 'Code modified successfully', stderr: '' };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mockResult = { exitCode: 0, stdout: 'Execution complete', stderr: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store execution record
|
||||||
|
if (!mockExecutionStore.has(loopId)) {
|
||||||
|
mockExecutionStore.set(loopId, []);
|
||||||
|
}
|
||||||
|
mockExecutionStore.get(loopId)!.push({
|
||||||
|
loopId,
|
||||||
|
stepId,
|
||||||
|
tool,
|
||||||
|
command: command || prompt || 'N/A',
|
||||||
|
...mockResult,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
exitCode: mockResult.exitCode,
|
||||||
|
stdout: mockResult.stdout,
|
||||||
|
stderr: mockResult.stderr
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/test/loop/run-full-scenario - Run a complete test scenario
|
||||||
|
if (pathname === '/api/test/loop/run-full-scenario' && req.method === 'POST') {
|
||||||
|
handlePostRequest(req, res, async (body) => {
|
||||||
|
const { scenario } = body as { scenario?: string };
|
||||||
|
|
||||||
|
// Reset mock store
|
||||||
|
mockExecutionStore.clear();
|
||||||
|
|
||||||
|
const scenarios: Record<string, any> = {
|
||||||
|
'test-fix': {
|
||||||
|
description: 'Test-Fix Loop Scenario',
|
||||||
|
steps: [
|
||||||
|
{ stepId: 'run_tests', tool: 'bash', command: 'npm test', expectedToFail: true },
|
||||||
|
{ stepId: 'analyze_failure', tool: 'gemini', prompt: 'Analyze failure' },
|
||||||
|
{ stepId: 'apply_fix', tool: 'codex', prompt: 'Apply fix' },
|
||||||
|
{ stepId: 'run_tests', tool: 'bash', command: 'npm test', expectedToPass: true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'performance-opt': {
|
||||||
|
description: 'Performance Optimization Loop Scenario',
|
||||||
|
steps: [
|
||||||
|
{ stepId: 'run_benchmark', tool: 'bash', command: 'npm run benchmark', expectedSlow: true },
|
||||||
|
{ stepId: 'analyze_bottleneck', tool: 'gemini', prompt: 'Analyze performance' },
|
||||||
|
{ stepId: 'optimize', tool: 'codex', prompt: 'Optimize code' },
|
||||||
|
{ stepId: 'run_benchmark', tool: 'bash', command: 'npm run benchmark', expectedFast: true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'doc-review': {
|
||||||
|
description: 'Documentation Review Loop Scenario',
|
||||||
|
steps: [
|
||||||
|
{ stepId: 'generate_docs', tool: 'bash', command: 'npm run docs' },
|
||||||
|
{ stepId: 'review_docs', tool: 'gemini', prompt: 'Review documentation' },
|
||||||
|
{ stepId: 'fix_docs', tool: 'codex', prompt: 'Fix documentation issues' },
|
||||||
|
{ stepId: 'final_review', tool: 'gemini', prompt: 'Final review' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedScenario = scenarios[scenario || 'test-fix'];
|
||||||
|
if (!selectedScenario) {
|
||||||
|
return { success: false, error: 'Invalid scenario. Available: test-fix, performance-opt, doc-review', status: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
scenario: selectedScenario.description,
|
||||||
|
steps: selectedScenario.steps,
|
||||||
|
instructions: 'Use POST /api/test/loop/mock/cli/execute for each step'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -28,6 +28,9 @@ import { handleLiteLLMRoutes } from './routes/litellm-routes.js';
|
|||||||
import { handleLiteLLMApiRoutes } from './routes/litellm-api-routes.js';
|
import { handleLiteLLMApiRoutes } from './routes/litellm-api-routes.js';
|
||||||
import { handleNavStatusRoutes } from './routes/nav-status-routes.js';
|
import { handleNavStatusRoutes } from './routes/nav-status-routes.js';
|
||||||
import { handleAuthRoutes } from './routes/auth-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 WebSocket handling
|
||||||
import { handleWebSocketUpgrade, broadcastToClients, extractSessionIdFromPath } from './websocket.js';
|
import { handleWebSocketUpgrade, broadcastToClients, extractSessionIdFromPath } from './websocket.js';
|
||||||
@@ -102,7 +105,8 @@ const MODULE_CSS_FILES = [
|
|||||||
'31-api-settings.css',
|
'31-api-settings.css',
|
||||||
'32-issue-manager.css',
|
'32-issue-manager.css',
|
||||||
'33-cli-stream-viewer.css',
|
'33-cli-stream-viewer.css',
|
||||||
'34-discovery.css'
|
'34-discovery.css',
|
||||||
|
'36-loop-monitor.css'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Modular JS files in dependency order
|
// Modular JS files in dependency order
|
||||||
@@ -162,6 +166,7 @@ const MODULE_FILES = [
|
|||||||
'views/help.js',
|
'views/help.js',
|
||||||
'views/issue-manager.js',
|
'views/issue-manager.js',
|
||||||
'views/issue-discovery.js',
|
'views/issue-discovery.js',
|
||||||
|
'views/loop-monitor.js',
|
||||||
'main.js'
|
'main.js'
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -359,7 +364,14 @@ function generateServerDashboard(initialPath: string): string {
|
|||||||
// Read and concatenate modular JS files in dependency order
|
// Read and concatenate modular JS files in dependency order
|
||||||
let jsContent = MODULE_FILES.map(file => {
|
let jsContent = MODULE_FILES.map(file => {
|
||||||
const filePath = join(MODULE_JS_DIR, 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');
|
}).join('\n\n');
|
||||||
|
|
||||||
// Inject CSS content
|
// Inject CSS content
|
||||||
@@ -556,6 +568,21 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
|||||||
if (await handleCcwRoutes(routeContext)) return;
|
if (await handleCcwRoutes(routeContext)) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loop routes (/api/loops*)
|
||||||
|
if (pathname.startsWith('/api/loops')) {
|
||||||
|
if (await handleLoopRoutes(routeContext)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task routes (/api/tasks)
|
||||||
|
if (pathname.startsWith('/api/tasks')) {
|
||||||
|
if (await handleTaskRoutes(routeContext)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test loop routes (/api/test/loop*)
|
||||||
|
if (pathname.startsWith('/api/test/loop')) {
|
||||||
|
if (await handleTestLoopRoutes(routeContext)) return;
|
||||||
|
}
|
||||||
|
|
||||||
// Skills routes (/api/skills*)
|
// Skills routes (/api/skills*)
|
||||||
if (pathname.startsWith('/api/skills')) {
|
if (pathname.startsWith('/api/skills')) {
|
||||||
if (await handleSkillsRoutes(routeContext)) return;
|
if (await handleSkillsRoutes(routeContext)) return;
|
||||||
|
|||||||
@@ -5,6 +5,64 @@ import type { Duplex } from 'stream';
|
|||||||
// WebSocket clients for real-time notifications
|
// WebSocket clients for real-time notifications
|
||||||
export const wsClients = new Set<Duplex>();
|
export const wsClients = new Set<Duplex>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket message types for Loop monitoring
|
||||||
|
*/
|
||||||
|
export type LoopMessageType =
|
||||||
|
| 'LOOP_STATE_UPDATE'
|
||||||
|
| 'LOOP_STEP_COMPLETED'
|
||||||
|
| 'LOOP_COMPLETED'
|
||||||
|
| 'LOOP_LOG_ENTRY';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loop State Update - fired when loop status changes
|
||||||
|
*/
|
||||||
|
export interface LoopStateUpdateMessage {
|
||||||
|
type: 'LOOP_STATE_UPDATE';
|
||||||
|
loop_id: string;
|
||||||
|
status: 'created' | 'running' | 'paused' | 'completed' | 'failed';
|
||||||
|
current_iteration: number;
|
||||||
|
current_cli_step: number;
|
||||||
|
updated_at: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loop Step Completed - fired when a CLI step finishes
|
||||||
|
*/
|
||||||
|
export interface LoopStepCompletedMessage {
|
||||||
|
type: 'LOOP_STEP_COMPLETED';
|
||||||
|
loop_id: string;
|
||||||
|
step_id: string;
|
||||||
|
exit_code: number;
|
||||||
|
duration_ms: number;
|
||||||
|
output: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loop Completed - fired when entire loop finishes
|
||||||
|
*/
|
||||||
|
export interface LoopCompletedMessage {
|
||||||
|
type: 'LOOP_COMPLETED';
|
||||||
|
loop_id: string;
|
||||||
|
final_status: 'completed' | 'failed';
|
||||||
|
total_iterations: number;
|
||||||
|
reason?: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loop Log Entry - fired for streaming log lines
|
||||||
|
*/
|
||||||
|
export interface LoopLogEntryMessage {
|
||||||
|
type: 'LOOP_LOG_ENTRY';
|
||||||
|
loop_id: string;
|
||||||
|
step_id: string;
|
||||||
|
line: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function handleWebSocketUpgrade(req: IncomingMessage, socket: Duplex, _head: Buffer): void {
|
export function handleWebSocketUpgrade(req: IncomingMessage, socket: Duplex, _head: Buffer): void {
|
||||||
const header = req.headers['sec-websocket-key'];
|
const header = req.headers['sec-websocket-key'];
|
||||||
const key = Array.isArray(header) ? header[0] : header;
|
const key = Array.isArray(header) ? header[0] : header;
|
||||||
@@ -196,3 +254,49 @@ export function extractSessionIdFromPath(filePath: string): string | null {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loop-specific broadcast with throttling
|
||||||
|
* Throttles LOOP_STATE_UPDATE messages to avoid flooding clients
|
||||||
|
*/
|
||||||
|
let lastLoopBroadcast = 0;
|
||||||
|
const LOOP_BROADCAST_THROTTLE = 1000; // 1 second
|
||||||
|
|
||||||
|
export type LoopMessage =
|
||||||
|
| Omit<LoopStateUpdateMessage, 'timestamp'>
|
||||||
|
| Omit<LoopStepCompletedMessage, 'timestamp'>
|
||||||
|
| Omit<LoopCompletedMessage, 'timestamp'>
|
||||||
|
| Omit<LoopLogEntryMessage, 'timestamp'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast loop state update with throttling
|
||||||
|
*/
|
||||||
|
export function broadcastLoopUpdate(message: LoopMessage): void {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Throttle LOOP_STATE_UPDATE to reduce WebSocket traffic
|
||||||
|
if (message.type === 'LOOP_STATE_UPDATE' && now - lastLoopBroadcast < LOOP_BROADCAST_THROTTLE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastLoopBroadcast = now;
|
||||||
|
|
||||||
|
broadcastToClients({
|
||||||
|
...message,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast loop log entry (no throttling)
|
||||||
|
* Used for streaming real-time logs to Dashboard
|
||||||
|
*/
|
||||||
|
export function broadcastLoopLog(loop_id: string, step_id: string, line: string): void {
|
||||||
|
broadcastToClients({
|
||||||
|
type: 'LOOP_LOG_ENTRY',
|
||||||
|
loop_id,
|
||||||
|
step_id,
|
||||||
|
line,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,6 +66,27 @@
|
|||||||
color: hsl(var(--muted-foreground));
|
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 {
|
.cli-tools-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
|||||||
1024
ccw/src/templates/dashboard-css/36-loop-monitor.css
Normal file
1024
ccw/src/templates/dashboard-css/36-loop-monitor.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -771,9 +771,14 @@ function renderCliStatus() {
|
|||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="cli-status-header">
|
<div class="cli-status-header">
|
||||||
<h3><i data-lucide="terminal" class="w-4 h-4"></i> CLI Tools</h3>
|
<h3><i data-lucide="terminal" class="w-4 h-4"></i> CLI Tools</h3>
|
||||||
<button class="btn-icon" onclick="refreshAllCliStatus()" title="Refresh">
|
<div class="cli-status-actions">
|
||||||
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
|
<button class="btn-icon" onclick="syncBuiltinTools()" title="Sync tool availability with installed CLI tools">
|
||||||
</button>
|
<i data-lucide="sync" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon" onclick="refreshAllCliStatus()" title="Refresh">
|
||||||
|
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${ccwInstallHtml}
|
${ccwInstallHtml}
|
||||||
<div class="cli-tools-grid">
|
<div class="cli-tools-grid">
|
||||||
@@ -825,6 +830,62 @@ function setPromptFormat(format) {
|
|||||||
showRefreshToast(`Prompt format set to ${format.toUpperCase()}`, 'success');
|
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) {
|
function setSmartContextEnabled(enabled) {
|
||||||
smartContextEnabled = enabled;
|
smartContextEnabled = enabled;
|
||||||
localStorage.setItem('ccw-smart-context', enabled.toString());
|
localStorage.setItem('ccw-smart-context', enabled.toString());
|
||||||
|
|||||||
@@ -183,6 +183,14 @@ function initNavigation() {
|
|||||||
} else {
|
} else {
|
||||||
console.error('renderIssueDiscovery not defined - please refresh the page');
|
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');
|
titleEl.textContent = t('title.issueManager');
|
||||||
} else if (currentView === 'issue-discovery') {
|
} else if (currentView === 'issue-discovery') {
|
||||||
titleEl.textContent = t('title.issueDiscovery');
|
titleEl.textContent = t('title.issueDiscovery');
|
||||||
|
} else if (currentView === 'loop-monitor') {
|
||||||
|
titleEl.textContent = t('title.loopMonitor') || 'Loop Monitor';
|
||||||
} else if (currentView === 'liteTasks') {
|
} 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' };
|
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');
|
titleEl.textContent = names[currentLiteType] || t('title.liteTasks');
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ const i18n = {
|
|||||||
'nav.liteFix': 'Lite Fix',
|
'nav.liteFix': 'Lite Fix',
|
||||||
'nav.multiCliPlan': 'Multi-CLI Plan',
|
'nav.multiCliPlan': 'Multi-CLI Plan',
|
||||||
|
|
||||||
|
// Sidebar - Loops section
|
||||||
|
'nav.loops': 'Loops',
|
||||||
|
'nav.loopMonitor': 'Monitor',
|
||||||
|
|
||||||
// Sidebar - MCP section
|
// Sidebar - MCP section
|
||||||
'nav.mcpServers': 'MCP Servers',
|
'nav.mcpServers': 'MCP Servers',
|
||||||
'nav.manage': 'Manage',
|
'nav.manage': 'Manage',
|
||||||
@@ -2144,6 +2148,51 @@ const i18n = {
|
|||||||
'title.issueManager': 'Issue Manager',
|
'title.issueManager': 'Issue Manager',
|
||||||
'title.issueDiscovery': 'Issue Discovery',
|
'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
|
// Issue Discovery
|
||||||
'discovery.title': 'Issue Discovery',
|
'discovery.title': 'Issue Discovery',
|
||||||
'discovery.description': 'Discover potential issues from multiple perspectives',
|
'discovery.description': 'Discover potential issues from multiple perspectives',
|
||||||
@@ -2438,6 +2487,10 @@ const i18n = {
|
|||||||
'nav.liteFix': '轻量修复',
|
'nav.liteFix': '轻量修复',
|
||||||
'nav.multiCliPlan': '多CLI规划',
|
'nav.multiCliPlan': '多CLI规划',
|
||||||
|
|
||||||
|
// Sidebar - Loops section
|
||||||
|
'nav.loops': '循环',
|
||||||
|
'nav.loopMonitor': '监控器',
|
||||||
|
|
||||||
// Sidebar - MCP section
|
// Sidebar - MCP section
|
||||||
'nav.mcpServers': 'MCP 服务器',
|
'nav.mcpServers': 'MCP 服务器',
|
||||||
'nav.manage': '管理',
|
'nav.manage': '管理',
|
||||||
@@ -4507,6 +4560,51 @@ const i18n = {
|
|||||||
'title.issueManager': '议题管理器',
|
'title.issueManager': '议题管理器',
|
||||||
'title.issueDiscovery': '议题发现',
|
'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
|
// Issue Discovery
|
||||||
'discovery.title': '议题发现',
|
'discovery.title': '议题发现',
|
||||||
'discovery.description': '从多个视角发现潜在问题',
|
'discovery.description': '从多个视角发现潜在问题',
|
||||||
|
|||||||
1002
ccw/src/templates/dashboard-js/views/loop-monitor.js
Normal file
1002
ccw/src/templates/dashboard-js/views/loop-monitor.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -525,6 +525,21 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Loops Section -->
|
||||||
|
<div class="mb-2" id="loopsNav">
|
||||||
|
<div class="flex items-center px-4 py-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
<i data-lucide="repeat" class="nav-section-icon mr-2"></i>
|
||||||
|
<span class="nav-section-title" data-i18n="nav.loops">Loops</span>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-0.5">
|
||||||
|
<li class="nav-item flex items-center gap-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-view="loop-monitor" data-tooltip="Loop Monitor">
|
||||||
|
<i data-lucide="activity" class="nav-icon text-cyan"></i>
|
||||||
|
<span class="nav-text flex-1" data-i18n="nav.loopMonitor">Monitor</span>
|
||||||
|
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-cyan-light text-cyan" id="badgeLoops">0</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Issues Section -->
|
<!-- Issues Section -->
|
||||||
<div class="mb-2" id="issuesNav">
|
<div class="mb-2" id="issuesNav">
|
||||||
<div class="flex items-center px-4 py-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
<div class="flex items-center px-4 py-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
|||||||
@@ -610,6 +610,19 @@ export function updateClaudeDefaultTool(
|
|||||||
return settings;
|
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'
|
* Add API endpoint as a tool with type: 'api-endpoint'
|
||||||
* Usage: --tool <name> or --tool custom --model <id>
|
* Usage: --tool <name> or --tool custom --model <id>
|
||||||
@@ -943,3 +956,133 @@ export function getFullConfigResponse(projectDir: string): {
|
|||||||
predefinedModels: { ...PREDEFINED_MODELS }
|
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<string, { available: boolean; enabled: boolean }>;
|
||||||
|
recommended: Record<string, { shouldEnable: boolean; reason: string }>;
|
||||||
|
}> {
|
||||||
|
// 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<string, { available: boolean; enabled: boolean }> = {};
|
||||||
|
const recommended: Record<string, { shouldEnable: boolean; reason: string }> = {};
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
519
ccw/src/tools/loop-manager.ts
Normal file
519
ccw/src/tools/loop-manager.ts
Normal file
@@ -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<string> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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, string>): 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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
// 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<LoopState> {
|
||||||
|
return this.stateManager.readState(loopId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all loops
|
||||||
|
*/
|
||||||
|
async listLoops(): Promise<LoopState[]> {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
173
ccw/src/tools/loop-state-manager.ts
Normal file
173
ccw/src/tools/loop-state-manager.ts
Normal file
@@ -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<LoopState> {
|
||||||
|
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<LoopState> {
|
||||||
|
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<LoopState>): Promise<LoopState> {
|
||||||
|
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<void> {
|
||||||
|
const filePath = this.getStateFilePath(loopId);
|
||||||
|
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
await unlink(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all loop states
|
||||||
|
*/
|
||||||
|
async listStates(): Promise<LoopState[]> {
|
||||||
|
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<LoopState> {
|
||||||
|
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<void> {
|
||||||
|
if (!existsSync(this.baseDir)) {
|
||||||
|
await mkdir(this.baseDir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write state file with automatic backup
|
||||||
|
*/
|
||||||
|
private async writeState(loopId: string, state: LoopState): Promise<void> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './tool.js';
|
export * from './tool.js';
|
||||||
export * from './session.js';
|
export * from './session.js';
|
||||||
export * from './config.js';
|
export * from './config.js';
|
||||||
|
export * from './loop.js';
|
||||||
|
|||||||
193
ccw/src/types/loop.ts
Normal file
193
ccw/src/types/loop.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State variables
|
||||||
|
* Key format: {step_id}_{stdout|stderr}
|
||||||
|
* Value: corresponding output content
|
||||||
|
*/
|
||||||
|
state_variables: Record<string, string>;
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
1041
tests/loop-comprehensive-test.js
Normal file
1041
tests/loop-comprehensive-test.js
Normal file
File diff suppressed because it is too large
Load Diff
26
tests/run-loop-comprehensive-test.sh
Normal file
26
tests/run-loop-comprehensive-test.sh
Normal file
@@ -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 $?
|
||||||
Reference in New Issue
Block a user