Files
Claude-Code-Workflow/ccw/src/core/routes/loop-v2-routes.ts
catlog22 ea04663035 fix(multi-cli): populate multiCliPlan sessions in liteTaskDataStore
Fix task click handlers not working in multi-CLI planning detail page.

Root cause: liteTaskDataStore was not being populated with multiCliPlan
sessions during initialization, so task click handlers couldn't access
session data using currentSessionDetailKey.

Changes:
- navigation.js: Add code to populate multiCliPlan sessions in liteTaskDataStore
- notifications.js: Add code to populate multiCliPlan sessions when data refreshes

Now when task detail page loads, liteTaskDataStore contains the correct key
'multi-cli-${sessionId}' matching currentSessionDetailKey, allowing task
click handlers to find session data and open detail drawer.

Verified: Task clicks now properly open detail panel for all 7 tasks.
2026-01-22 15:41:01 +08:00

1471 lines
49 KiB
TypeScript

/**
* Loop V2 Routes Module
* CCW Loop System - Simplified HTTP API endpoints for Dashboard
* Provides simplified loop CRUD operations independent of task files
*
* Loop Endpoints:
* - GET /api/loops/v2 - List all loops with pagination
* - POST /api/loops/v2 - Create loop with {title, description, max_iterations}
* - GET /api/loops/v2/:loopId - Get loop details
* - PUT /api/loops/v2/:loopId - Update loop metadata (title, description, max_iterations, tags, priority, notes)
* - PATCH /api/loops/v2/:loopId/status - Quick status update with {status}
* - DELETE /api/loops/v2/:loopId - Delete loop
* - POST /api/loops/v2/:loopId/start - Start loop execution
* - POST /api/loops/v2/:loopId/pause - Pause loop
* - POST /api/loops/v2/:loopId/resume - Resume loop
* - POST /api/loops/v2/:loopId/stop - Stop loop
*
* Task Management Endpoints:
* - POST /api/loops/v2/:loopId/tasks - Add task to loop
* - GET /api/loops/v2/:loopId/tasks - List all tasks for loop
* - PUT /api/loops/v2/tasks/:taskId - Update task (requires loop_id in body)
* - DELETE /api/loops/v2/tasks/:taskId - Delete task (requires loop_id query param)
* - PUT /api/loops/v2/:loopId/tasks/reorder - Reorder tasks with {ordered_task_ids: string[]}
*
* Advanced Task Features:
* - POST /api/loops/v2/:loopId/import - Import tasks from issue with {issue_id}
* - POST /api/loops/v2/:loopId/generate - Generate tasks via Gemini with {tool?, count?}
*/
import { join } from 'path';
import { randomBytes } from 'crypto';
import * as os from 'os';
import type { RouteContext } from './types.js';
import { LoopStatus } from '../../types/loop.js';
import type { LoopState } from '../../types/loop.js';
import { TaskStorageManager, type TaskCreateRequest, type TaskUpdateRequest, type TaskReorderRequest } from '../../tools/loop-task-manager.js';
import { executeCliTool } from '../../tools/cli-executor.js';
import { loadClaudeCliTools } from '../../tools/claude-cli-tools.js';
/**
* Module-level cache for CLI tools configuration
* Loaded once at server startup to avoid repeated file I/O
*/
let cachedEnabledTools: string[] | null = null;
/**
* Initialize CLI tools cache at server startup
* Should be called once when the server starts
*/
export function initializeCliToolsCache(): void {
try {
const cliToolsConfig = loadClaudeCliTools(os.homedir());
const enabledTools = Object.entries(cliToolsConfig.tools || {})
.filter(([_, config]) => config.enabled === true)
.map(([name]) => name);
cachedEnabledTools = ['bash', ...enabledTools];
console.log('[Loop V2] CLI tools cache initialized:', cachedEnabledTools);
} catch (err) {
console.error('[Loop V2] Failed to initialize CLI tools cache:', err);
// Fallback to basic tools if config loading fails
cachedEnabledTools = ['bash', 'gemini', 'qwen', 'codex', 'claude'];
}
}
/**
* Clear CLI tools cache (for testing or config reload)
*/
export function clearCliToolsCache(): void {
cachedEnabledTools = null;
}
/**
* V2 Loop Create Request
*/
interface V2LoopCreateRequest {
title: string;
description?: string;
max_iterations?: number;
}
/**
* V2 Loop Update Request (extended)
*/
interface V2LoopUpdateRequest {
// Basic fields
title?: string;
description?: string;
max_iterations?: number;
// Extended metadata fields
tags?: string[];
priority?: 'low' | 'medium' | 'high';
notes?: string;
}
/**
* V2 Loop Storage Format (simplified, independent of task files)
*/
interface V2LoopStorage {
loop_id: string;
title: string;
description: string;
max_iterations: number;
status: LoopStatus;
current_iteration: number;
created_at: string;
updated_at: string;
completed_at?: string;
failure_reason?: string;
// Extended metadata fields
tags?: string[];
priority?: 'low' | 'medium' | 'high';
notes?: string;
// Tasks stored in separate tasks.jsonl file
}
/**
* Handle V2 loop routes
* @returns true if route was handled, false otherwise
*/
export async function handleLoopV2Routes(ctx: RouteContext): Promise<boolean> {
const { pathname, req, res, initialPath, handlePostRequest, url, broadcastToClients } = ctx;
// Get workflow directory from initialPath
const workflowDir = initialPath || process.cwd();
const loopDir = join(workflowDir, '.workflow', '.loop');
// Helper to broadcast loop state updates
const broadcastStateUpdate = (loopId: string, status: LoopStatus): void => {
try {
broadcastToClients({
type: 'LOOP_STATE_UPDATE',
loop_id: loopId,
status: status as 'created' | 'running' | 'paused' | 'completed' | 'failed',
updated_at: new Date().toISOString()
});
} catch (error) {
// Silently ignore broadcast errors
}
};
// Helper to generate loop ID
const generateLoopId = (): string => {
const timestamp = new Date().toISOString().replace(/[-:]/g, '').split('.')[0];
const random = randomBytes(4).toString('hex');
return `loop-v2-${timestamp}-${random}`;
};
// Helper to read loop storage
const readLoopStorage = async (loopId: string): Promise<V2LoopStorage | null> => {
const { readFile } = await import('fs/promises');
const { existsSync } = await import('fs');
const filePath = join(loopDir, `${loopId}.json`);
if (!existsSync(filePath)) {
return null;
}
try {
const content = await readFile(filePath, 'utf-8');
return JSON.parse(content) as V2LoopStorage;
} catch {
return null;
}
};
// Helper to write loop storage
const writeLoopStorage = async (loop: V2LoopStorage): Promise<void> => {
const { writeFile, mkdir } = await import('fs/promises');
const { existsSync } = await import('fs');
if (!existsSync(loopDir)) {
await mkdir(loopDir, { recursive: true });
}
const filePath = join(loopDir, `${loop.loop_id}.json`);
await writeFile(filePath, JSON.stringify(loop, null, 2), 'utf-8');
};
// Helper to delete loop storage
const deleteLoopStorage = async (loopId: string): Promise<void> => {
const { unlink } = await import('fs/promises');
const { existsSync } = await import('fs');
const filePath = join(loopDir, `${loopId}.json`);
if (existsSync(filePath)) {
await unlink(filePath);
}
// Also delete tasks.jsonl if exists
const tasksPath = join(loopDir, `${loopId}.tasks.jsonl`);
if (existsSync(tasksPath)) {
await unlink(tasksPath).catch(() => {});
}
};
// Helper to list all loops
const listLoops = async (): Promise<V2LoopStorage[]> => {
const { readdir } = await import('fs/promises');
const { existsSync } = await import('fs');
if (!existsSync(loopDir)) {
return [];
}
const files = await readdir(loopDir);
const loopFiles = files.filter(f => f.startsWith('loop-v2-') && f.endsWith('.json'));
const loops: V2LoopStorage[] = [];
for (const file of loopFiles) {
const loopId = file.replace('.json', '');
const loop = await readLoopStorage(loopId);
if (loop) {
loops.push(loop);
}
}
return loops;
};
// ==== EXACT PATH ROUTES ====
// POST /api/loops/v2 - Create loop with simplified fields
if (pathname === '/api/loops/v2' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { title, description, max_iterations } = body as V2LoopCreateRequest;
// Validation
if (!title || typeof title !== 'string' || title.trim().length === 0) {
return { success: false, error: 'title is required and must be non-empty', status: 400 };
}
if (description !== undefined && typeof description !== 'string') {
return { success: false, error: 'description must be a string', status: 400 };
}
if (max_iterations !== undefined && (typeof max_iterations !== 'number' || max_iterations < 1)) {
return { success: false, error: 'max_iterations must be a positive number', status: 400 };
}
try {
const loopId = generateLoopId();
const now = new Date().toISOString();
const loop: V2LoopStorage = {
loop_id: loopId,
title: title.trim(),
description: description?.trim() || '',
max_iterations: max_iterations || 10,
status: LoopStatus.CREATED,
current_iteration: 0,
created_at: now,
updated_at: now
};
await writeLoopStorage(loop);
// Broadcast creation
broadcastStateUpdate(loopId, LoopStatus.CREATED);
return { success: true, data: loop };
} catch (error) {
return { success: false, error: (error as Error).message, status: 500 };
}
});
return true;
}
// GET /api/loops/v2 - List all loops with pagination
if (pathname === '/api/loops/v2' && req.method === 'GET') {
try {
const loops = await listLoops();
// Parse query params for pagination and 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()
);
// Parse pagination params
const limit = parseInt(searchParams?.get('limit') || '50', 10);
const offset = parseInt(searchParams?.get('offset') || '0', 10);
// Apply pagination
const paginatedLoops = filteredLoops.slice(offset, offset + limit);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
data: paginatedLoops,
total: filteredLoops.length,
limit,
offset,
hasMore: offset + limit < 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) ====
// POST /api/loops/v2/:loopId/start - Start loop execution
if (pathname.match(/\/api\/loops\/v2\/[^/]+\/start$/) && 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 loop = await readLoopStorage(loopId);
if (!loop) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
return true;
}
// Can only start created or paused loops
if (!['created', 'paused'].includes(loop.status.toLowerCase())) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
error: `Cannot start loop with status: ${loop.status}`
}));
return true;
}
// Update loop status
loop.status = LoopStatus.RUNNING;
loop.updated_at = new Date().toISOString();
await writeLoopStorage(loop);
// Broadcast state update
broadcastStateUpdate(loopId, LoopStatus.RUNNING);
// Trigger ccw-loop skill execution (non-blocking)
// The skill will check status before each action and exit gracefully on pause/stop
executeCliTool({
tool: 'claude',
prompt: `/ccw-loop --loop-id ${loopId} --auto`,
mode: 'write',
workingDir: workflowDir
}).catch((error) => {
// Log error but don't fail the start request
console.error(`Failed to trigger ccw-loop skill for ${loopId}:`, error);
// Update loop status to failed
readLoopStorage(loopId).then(async (failedLoop) => {
if (failedLoop) {
failedLoop.status = LoopStatus.FAILED;
failedLoop.failure_reason = `Skill execution failed: ${error.message}`;
failedLoop.completed_at = new Date().toISOString();
await writeLoopStorage(failedLoop);
broadcastStateUpdate(loopId, LoopStatus.FAILED);
}
});
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, data: loop, message: 'Loop started' }));
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/v2/:loopId/pause - Pause loop
if (pathname.match(/\/api\/loops\/v2\/[^/]+\/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 {
const loop = await readLoopStorage(loopId);
if (!loop) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
return true;
}
// Can only pause running loops
if (loop.status !== LoopStatus.RUNNING) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
error: `Cannot pause loop with status: ${loop.status}`
}));
return true;
}
loop.status = LoopStatus.PAUSED;
loop.updated_at = new Date().toISOString();
await writeLoopStorage(loop);
broadcastStateUpdate(loopId, LoopStatus.PAUSED);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, data: loop, 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/v2/:loopId/resume - Resume loop
if (pathname.match(/\/api\/loops\/v2\/[^/]+\/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 {
const loop = await readLoopStorage(loopId);
if (!loop) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
return true;
}
// Can only resume paused loops
if (loop.status !== LoopStatus.PAUSED) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
error: `Cannot resume loop with status: ${loop.status}`
}));
return true;
}
loop.status = LoopStatus.RUNNING;
loop.updated_at = new Date().toISOString();
await writeLoopStorage(loop);
broadcastStateUpdate(loopId, LoopStatus.RUNNING);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, data: loop, 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/v2/:loopId/stop - Stop loop
if (pathname.match(/\/api\/loops\/v2\/[^/]+\/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 {
const loop = await readLoopStorage(loopId);
if (!loop) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
return true;
}
// Can only stop running or paused loops
if (![LoopStatus.RUNNING, LoopStatus.PAUSED, LoopStatus.CREATED].includes(loop.status)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
error: `Cannot stop loop with status: ${loop.status}`
}));
return true;
}
loop.status = LoopStatus.FAILED;
loop.failure_reason = 'Manually stopped by user';
loop.completed_at = new Date().toISOString();
loop.updated_at = loop.completed_at;
await writeLoopStorage(loop);
broadcastStateUpdate(loopId, LoopStatus.FAILED);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, data: loop, 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;
}
}
// ==== SINGLE PARAM ROUTES (must come after nested routes) ====
// GET /api/loops/v2/:loopId - Get loop details
if (pathname.match(/^\/api\/loops\/v2\/[^/]+$/) && 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 loop = await readLoopStorage(loopId);
if (!loop) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
return true;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, data: loop }));
return true;
} catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
return true;
}
}
// PUT /api/loops/v2/:loopId - Update loop metadata
if (pathname.match(/^\/api\/loops\/v2\/[^/]+$/) && req.method === 'PUT') {
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;
}
handlePostRequest(req, res, async (body) => {
const { title, description, max_iterations, tags, priority, notes } = body as V2LoopUpdateRequest;
try {
const loop = await readLoopStorage(loopId);
if (!loop) {
return { success: false, error: 'Loop not found', status: 404 };
}
// Can only update created or paused loops
if (![LoopStatus.CREATED, LoopStatus.PAUSED, LoopStatus.FAILED, LoopStatus.COMPLETED].includes(loop.status)) {
return { success: false, error: `Cannot update loop with status: ${loop.status}`, status: 400 };
}
// Validate and apply updates
if (title !== undefined) {
if (typeof title !== 'string' || title.trim().length === 0) {
return { success: false, error: 'title must be a non-empty string', status: 400 };
}
loop.title = title.trim();
}
if (description !== undefined) {
if (typeof description !== 'string') {
return { success: false, error: 'description must be a string', status: 400 };
}
loop.description = description.trim();
}
if (max_iterations !== undefined) {
if (typeof max_iterations !== 'number' || max_iterations < 1) {
return { success: false, error: 'max_iterations must be a positive number', status: 400 };
}
loop.max_iterations = max_iterations;
}
// Extended metadata fields
if (tags !== undefined) {
if (!Array.isArray(tags) || !tags.every(t => typeof t === 'string')) {
return { success: false, error: 'tags must be an array of strings', status: 400 };
}
loop.tags = tags;
}
if (priority !== undefined) {
if (!['low', 'medium', 'high'].includes(priority)) {
return { success: false, error: 'priority must be one of: low, medium, high', status: 400 };
}
loop.priority = priority;
}
if (notes !== undefined) {
if (typeof notes !== 'string') {
return { success: false, error: 'notes must be a string', status: 400 };
}
loop.notes = notes.trim();
}
loop.updated_at = new Date().toISOString();
await writeLoopStorage(loop);
broadcastStateUpdate(loopId, loop.status);
return { success: true, data: loop };
} catch (error) {
return { success: false, error: (error as Error).message, status: 500 };
}
});
return true;
}
// PATCH /api/loops/v2/:loopId/status - Quick status update
if (pathname.match(/\/api\/loops\/v2\/[^/]+\/status$/) && req.method === 'PATCH') {
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;
}
handlePostRequest(req, res, async (body) => {
const { status } = body as { status?: string };
if (!status || typeof status !== 'string') {
return { success: false, error: 'status is required', status: 400 };
}
if (!Object.values(LoopStatus).includes(status as LoopStatus)) {
return { success: false, error: `Invalid status: ${status}`, status: 400 };
}
try {
const loop = await readLoopStorage(loopId);
if (!loop) {
return { success: false, error: 'Loop not found', status: 404 };
}
loop.status = status as LoopStatus;
loop.updated_at = new Date().toISOString();
if (status === LoopStatus.COMPLETED && !loop.completed_at) {
loop.completed_at = new Date().toISOString();
}
await writeLoopStorage(loop);
broadcastStateUpdate(loopId, loop.status);
return { success: true, data: loop };
} catch (error) {
return { success: false, error: (error as Error).message, status: 500 };
}
});
return true;
}
// DELETE /api/loops/v2/:loopId - Delete loop
if (pathname.match(/^\/api\/loops\/v2\/[^/]+$/) && req.method === 'DELETE') {
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 loop = await readLoopStorage(loopId);
if (!loop) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
return true;
}
// Cannot delete running loops
if (loop.status === LoopStatus.RUNNING) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
error: 'Cannot delete running loop. Stop it first.'
}));
return true;
}
await deleteLoopStorage(loopId);
// Broadcast deletion
try {
broadcastToClients({
type: 'LOOP_DELETED',
loop_id: loopId
});
} catch {
// Ignore broadcast errors
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, message: 'Loop deleted' }));
return true;
} catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
return true;
}
}
// ==== TASK MANAGEMENT ENDPOINTS ====
// Helper to create TaskStorageManager instance
const createTaskManager = (): TaskStorageManager => {
return new TaskStorageManager(workflowDir);
};
// POST /api/loops/v2/:loopId/tasks - Add task to loop
if (pathname.match(/\/api\/loops\/v2\/[^/]+\/tasks$/) && 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;
}
handlePostRequest(req, res, async (body) => {
const { description, tool, mode, prompt_template, command, on_error } = body as TaskCreateRequest;
// Validation
if (!description || typeof description !== 'string' || description.trim().length === 0) {
return { success: false, error: 'description is required', status: 400 };
}
if (!tool || typeof tool !== 'string') {
return { success: false, error: 'tool is required', status: 400 };
}
// Get enabled tools from cli-tools.json dynamically
const cliToolsConfig = loadClaudeCliTools(os.homedir());
const enabledTools = Object.entries(cliToolsConfig.tools || {})
.filter(([_, config]) => config.enabled === true)
.map(([name]) => name);
// Also allow 'bash' as a special case (built-in tool)
const validTools = ['bash', ...enabledTools];
if (!validTools.includes(tool)) {
return { success: false, error: `tool must be one of enabled tools: ${validTools.join(', ')}`, status: 400 };
}
if (!mode || typeof mode !== 'string') {
return { success: false, error: 'mode is required', status: 400 };
}
const validModes = ['analysis', 'write', 'review'];
if (!validModes.includes(mode)) {
return { success: false, error: `mode must be one of: ${validModes.join(', ')}`, status: 400 };
}
if (!prompt_template || typeof prompt_template !== 'string' || prompt_template.trim().length === 0) {
return { success: false, error: 'prompt_template is required', status: 400 };
}
try {
const taskManager = createTaskManager();
const task = await taskManager.addTask(loopId, {
description: description.trim(),
tool,
mode,
prompt_template: prompt_template.trim(),
command,
on_error
});
// Broadcast task added
try {
broadcastToClients({
type: 'TASK_ADDED',
loop_id: loopId,
task_id: task.task_id,
task: task
});
} catch {
// Ignore broadcast errors
}
return { success: true, data: task };
} catch (error) {
return { success: false, error: (error as Error).message, status: 500 };
}
});
return true;
}
// GET /api/loops/v2/:loopId/tasks - List all tasks for loop
if (pathname.match(/\/api\/loops\/v2\/[^/]+\/tasks$/) && 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 taskManager = createTaskManager();
const tasks = await taskManager.getTasks(loopId);
// Sort by order
tasks.sort((a, b) => a.order - b.order);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
data: tasks,
total: tasks.length,
loop_id: loopId,
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;
}
}
// GET /api/loops/v2/tasks/:taskId - Get single task (taskId lookup)
if (pathname.match(/^\/api\/loops\/v2\/tasks\/[^/]+$/) && req.method === 'GET') {
const taskId = pathname.split('/').pop();
if (!taskId || !isValidId(taskId)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'Invalid task ID format' }));
return true;
}
try {
const taskManager = createTaskManager();
// Get all loops and search for the task
const loops = await listLoops();
let foundTask = null;
let foundLoopId = null;
for (const loop of loops) {
const loopId = loop.loop_id;
try {
const tasks = await taskManager.getTasks(loopId);
const task = tasks.find(t => t.task_id === taskId);
if (task) {
foundTask = task;
foundLoopId = loopId;
break;
}
} catch {
continue;
}
}
if (!foundTask) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'Task not found' }));
return true;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
data: { ...foundTask, loop_id: foundLoopId },
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;
}
}
// PUT /api/loops/v2/tasks/:taskId - Update task (taskId lookup)
if (pathname.match(/^\/api\/loops\/v2\/tasks\/[^/]+$/) && req.method === 'PUT') {
const taskId = pathname.split('/').pop();
if (!taskId || !isValidId(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 { loop_id, description, tool, mode, prompt_template, command, on_error } = body as TaskUpdateRequest & { loop_id?: string };
if (!loop_id || typeof loop_id !== 'string') {
return { success: false, error: 'loop_id is required', status: 400 };
}
if (!isValidId(loop_id)) {
return { success: false, error: 'Invalid loop_id format', status: 400 };
}
try {
const taskManager = createTaskManager();
const updatedTask = await taskManager.updateTask(loop_id, taskId, {
description,
tool,
mode,
prompt_template,
command,
on_error
});
if (!updatedTask) {
return { success: false, error: 'Task not found', status: 404 };
}
// Broadcast task updated
try {
broadcastToClients({
type: 'TASK_UPDATED',
loop_id: loop_id,
task_id: taskId,
task: updatedTask
});
} catch {
// Ignore broadcast errors
}
return { success: true, data: updatedTask };
} catch (error) {
return { success: false, error: (error as Error).message, status: 500 };
}
});
return true;
}
// DELETE /api/loops/v2/tasks/:taskId - Delete task (taskId lookup)
if (pathname.match(/^\/api\/loops\/v2\/tasks\/[^/]+$/) && req.method === 'DELETE') {
const taskId = pathname.split('/').pop();
if (!taskId || !isValidId(taskId)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'Invalid task ID format' }));
return true;
}
// Get loop_id from query parameter
const urlObj = new URL(req.url || '', `http://localhost`);
const loopId = urlObj.searchParams.get('loop_id');
if (!loopId || !isValidId(loopId)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'loop_id query parameter is required' }));
return true;
}
try {
const taskManager = createTaskManager();
const deleted = await taskManager.deleteTask(loopId, taskId);
if (!deleted) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'Task not found' }));
return true;
}
// Broadcast task deleted
try {
broadcastToClients({
type: 'TASK_DELETED',
loop_id: loopId,
task_id: taskId
});
} catch {
// Ignore broadcast errors
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, message: 'Task deleted' }));
return true;
} catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
return true;
}
}
// PUT /api/loops/v2/:loopId/tasks/reorder - Reorder tasks
if (pathname.match(/\/api\/loops\/v2\/[^/]+\/tasks\/reorder$/) && req.method === 'PUT') {
const loopId = pathname.split('/').slice(-3)[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;
}
handlePostRequest(req, res, async (body) => {
const { ordered_task_ids } = body as TaskReorderRequest;
if (!ordered_task_ids || !Array.isArray(ordered_task_ids)) {
return { success: false, error: 'ordered_task_ids must be an array', status: 400 };
}
if (ordered_task_ids.length === 0) {
return { success: false, error: 'ordered_task_ids cannot be empty', status: 400 };
}
try {
const taskManager = createTaskManager();
const reorderedTasks = await taskManager.reorderTasks(loopId, { ordered_task_ids });
// Broadcast tasks reordered
try {
broadcastToClients({
type: 'TASK_REORDERED',
loop_id: loopId,
ordered_task_ids: ordered_task_ids,
tasks: reorderedTasks
});
} catch {
// Ignore broadcast errors
}
return { success: true, data: reorderedTasks };
} catch (error) {
return { success: false, error: (error as Error).message, status: 500 };
}
});
return true;
}
// ==== ADVANCED TASK FEATURES ====
// POST /api/loops/v2/:loopId/import - Import tasks from issue
if (pathname.match(/\/api\/loops\/v2\/[^/]+\/import$/) && 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;
}
handlePostRequest(req, res, async (body) => {
const { issue_id } = body as { issue_id?: string };
if (!issue_id || typeof issue_id !== 'string') {
return { success: false, error: 'issue_id is required', status: 400 };
}
try {
// Fetch issue data from issue-manager
const { readFile } = await import('fs/promises');
const { existsSync } = await import('fs');
const issuesDir = join(workflowDir, '.workflow', 'issues');
const issuesPath = join(issuesDir, 'issues.jsonl');
let issueData: any = null;
// Try reading from active issues
if (existsSync(issuesPath)) {
const content = await readFile(issuesPath, 'utf-8');
const issues = content.split('\n').filter(line => line.trim()).map(line => JSON.parse(line));
issueData = issues.find((i: any) => i.id === issue_id);
}
// Try reading from history if not found
if (!issueData) {
const historyPath = join(issuesDir, 'issue-history.jsonl');
if (existsSync(historyPath)) {
const content = await readFile(historyPath, 'utf-8');
const historyIssues = content.split('\n').filter(line => line.trim()).map(line => JSON.parse(line));
issueData = historyIssues.find((i: any) => i.id === issue_id);
}
}
if (!issueData) {
return { success: false, error: `Issue ${issue_id} not found`, status: 404 };
}
// Load solutions to get bound solution tasks
const solutionsPath = join(issuesDir, 'solutions', `${issue_id}.jsonl`);
let tasksToImport: any[] = [];
if (existsSync(solutionsPath)) {
const solutionsContent = await readFile(solutionsPath, 'utf-8');
const solutions = solutionsContent.split('\n').filter(line => line.trim()).map(line => JSON.parse(line));
// Get tasks from bound solution
const boundSolution = solutions.find((s: any) => s.id === issueData.bound_solution_id) ||
solutions.find((s: any) => s.is_bound) ||
solutions[0];
if (boundSolution?.tasks) {
tasksToImport = boundSolution.tasks;
}
}
if (tasksToImport.length === 0) {
return { success: false, error: 'No tasks found in issue. Bind a solution with tasks first.', status: 400 };
}
// Broadcast import start
broadcastToClients({
type: 'LOOP_TASK_IMPORT_PROGRESS',
loop_id: loopId,
payload: {
stage: 'starting',
total: tasksToImport.length,
imported: 0
}
});
const taskManager = createTaskManager();
const createdTasks: any[] = [];
// Convert issue tasks to loop tasks
for (let i = 0; i < tasksToImport.length; i++) {
const issueTask = tasksToImport[i];
// Map issue task fields to loop task fields
const taskRequest: TaskCreateRequest = {
description: issueTask.description || issueTask.title || `Task ${i + 1}`,
tool: mapIssueToolToLoopTool(issueTask.tool) || 'gemini',
mode: mapIssueModeToLoopMode(issueTask.mode) || 'write',
prompt_template: issueTask.prompt_template || issueTask.prompt || `Execute: ${issueTask.description || issueTask.title}`,
command: issueTask.command,
on_error: mapIssueOnError(issueTask.on_error)
};
const task = await taskManager.addTask(loopId, taskRequest);
createdTasks.push(task);
// Broadcast progress
broadcastToClients({
type: 'LOOP_TASK_IMPORT_PROGRESS',
loop_id: loopId,
payload: {
stage: 'importing',
total: tasksToImport.length,
imported: i + 1,
current_task: task
}
});
}
// Broadcast completion
broadcastToClients({
type: 'LOOP_TASK_IMPORT_COMPLETE',
loop_id: loopId,
payload: {
total: tasksToImport.length,
imported: createdTasks.length,
tasks: createdTasks
}
});
return {
success: true,
data: createdTasks,
message: `Imported ${createdTasks.length} tasks from issue ${issue_id}`
};
} catch (error) {
return { success: false, error: (error as Error).message, status: 500 };
}
});
return true;
}
// POST /api/loops/v2/:loopId/generate - Generate tasks via Gemini
if (pathname.match(/\/api\/loops\/v2\/[^/]+\/generate$/) && 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;
}
handlePostRequest(req, res, async (body) => {
const { tool = 'gemini', count } = body as { tool?: string; count?: number };
try {
// Get loop details for context
const loop = await readLoopStorage(loopId);
if (!loop) {
return { success: false, error: 'Loop not found', status: 404 };
}
// Broadcast generation start
broadcastToClients({
type: 'LOOP_TASK_GENERATION_PROGRESS',
loop_id: loopId,
payload: {
stage: 'analyzing',
message: 'Analyzing loop description...'
}
});
// Build generation prompt
const generatePrompt = `PURPOSE: Generate ${count || 5} specific tasks for loop execution
TASK: Analyze the loop description and generate a list of actionable tasks that can be executed via CLI tools. Each task should have clear description, tool selection, mode, and prompt template.
MODE: analysis
CONTEXT: Loop title: ${loop.title}
Loop description: ${loop.description || 'No description provided'}
Max iterations: ${loop.max_iterations}
EXPECTED: Return a JSON array of tasks with this exact structure:
[
{
"description": "Clear task description",
"tool": "gemini|codex|qwen|bash",
"mode": "analysis|write|review",
"prompt_template": "PURPOSE: ... TASK: ... MODE: analysis CONTEXT: @**/* EXPECTED: ...",
"on_error": "continue|pause|fail_fast"
}
]
CONSTRAINTS: Generate ${count || 5} tasks | Use gemini for AI tasks | Use bash for CLI commands | Include error handling strategy`;
// Call CLI with gemini to generate tasks
let generatedTasks: any[] = [];
let outputBuffer = '';
const result = await executeCliTool({
tool: tool === 'codex' || tool === 'qwen' || tool === 'gemini' ? tool : 'gemini',
prompt: generatePrompt,
mode: 'analysis',
format: 'plain',
cd: workflowDir,
timeout: 120000, // 2 minutes timeout
stream: true
}, (unit) => {
// Collect output
outputBuffer += unit.content;
// Broadcast partial output for progress
broadcastToClients({
type: 'LOOP_TASK_GENERATION_PROGRESS',
loop_id: loopId,
payload: {
stage: 'generating',
message: 'Generating tasks...',
output: unit.content
}
});
});
if (!result.success) {
return { success: false, error: 'Failed to generate tasks via CLI', status: 500 };
}
// Parse generated tasks from CLI output
generatedTasks = parseGeneratedTasks(outputBuffer);
if (generatedTasks.length === 0) {
return {
success: false,
error: 'No valid tasks generated. Check CLI output for details.',
status: 500,
output: outputBuffer
};
}
// Broadcast import start
broadcastToClients({
type: 'LOOP_TASK_GENERATION_PROGRESS',
loop_id: loopId,
payload: {
stage: 'importing',
message: `Importing ${generatedTasks.length} generated tasks...`,
total: generatedTasks.length,
imported: 0
}
});
const taskManager = createTaskManager();
const createdTasks: any[] = [];
// Add generated tasks to loop
for (let i = 0; i < generatedTasks.length; i++) {
const genTask = generatedTasks[i];
const taskRequest: TaskCreateRequest = {
description: genTask.description || `Generated Task ${i + 1}`,
tool: validateTool(genTask.tool) ? genTask.tool : 'gemini',
mode: validateMode(genTask.mode) ? genTask.mode : 'write',
prompt_template: genTask.prompt_template || `Execute task: ${genTask.description}`,
command: genTask.command,
on_error: validateOnError(genTask.on_error) ? genTask.on_error : 'continue'
};
const task = await taskManager.addTask(loopId, taskRequest);
createdTasks.push(task);
// Broadcast progress
broadcastToClients({
type: 'LOOP_TASK_GENERATION_PROGRESS',
loop_id: loopId,
payload: {
stage: 'importing',
message: `Importing task ${i + 1}/${generatedTasks.length}...`,
total: generatedTasks.length,
imported: i + 1,
current_task: task
}
});
}
// Broadcast completion
broadcastToClients({
type: 'LOOP_TASK_GENERATION_COMPLETE',
loop_id: loopId,
payload: {
total: generatedTasks.length,
imported: createdTasks.length,
tasks: createdTasks
}
});
return {
success: true,
data: createdTasks,
message: `Generated and imported ${createdTasks.length} tasks`
};
} catch (error) {
return { success: false, error: (error as Error).message, status: 500 };
}
});
return true;
}
return false;
}
/**
* 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;
}
/**
* Get enabled tools list from cache
* If cache is not initialized, it will load from config (fallback for lazy initialization)
*/
function getEnabledToolsList(): string[] {
// Return cached value if available
if (cachedEnabledTools) {
return cachedEnabledTools;
}
// Fallback: lazy initialization if cache not initialized (shouldn't happen in normal operation)
console.warn('[Loop V2] CLI tools cache not initialized, performing lazy load');
initializeCliToolsCache();
return cachedEnabledTools || ['bash', 'gemini', 'qwen', 'codex', 'claude'];
}
/**
* Map issue tool to loop tool
*/
function mapIssueToolToLoopTool(tool: any): string | null {
const validTools = getEnabledToolsList();
if (validTools.includes(tool)) return tool;
// Map aliases
if (tool === 'ccw') return 'gemini';
if (tool === 'ai') return 'gemini';
return null;
}
/**
* Map issue mode to loop mode
*/
function mapIssueModeToLoopMode(mode: any): 'analysis' | 'write' | 'review' | null {
const validModes = ['analysis', 'write', 'review'];
if (validModes.includes(mode)) return mode as any;
// Map aliases
if (mode === 'read') return 'analysis';
if (mode === 'create' || mode === 'modify') return 'write';
return null;
}
/**
* Map issue on_error value
*/
function mapIssueOnError(onError: any): 'continue' | 'pause' | 'fail_fast' | undefined {
const validValues = ['continue', 'pause', 'fail_fast'];
if (validValues.includes(onError)) return onError as any;
// Map aliases
if (onError === 'stop') return 'pause';
if (onError === 'abort') return 'fail_fast';
return undefined;
}
/**
* Validate tool value
*/
function validateTool(tool: any): boolean {
const validTools = getEnabledToolsList();
return validTools.includes(tool);
}
/**
* Validate mode value
*/
function validateMode(mode: any): boolean {
const validModes = ['analysis', 'write', 'review'];
return validModes.includes(mode);
}
/**
* Validate on_error value
*/
function validateOnError(onError: any): boolean {
const validValues = ['continue', 'pause', 'fail_fast'];
return validValues.includes(onError);
}
/**
* Parse generated tasks from CLI output
* Extracts JSON array from output, handles various response formats
*/
function parseGeneratedTasks(output: string): any[] {
let tasks: any[] = [];
// Try to find JSON array in output
const jsonMatch = output.match(/\[[\s\S]*\]/);
if (jsonMatch) {
try {
tasks = JSON.parse(jsonMatch[0]);
} catch {
// Invalid JSON, try alternative parsing
}
}
// If no valid JSON array found, try parsing line by line
if (tasks.length === 0) {
const lines = output.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
try {
tasks.push(JSON.parse(trimmed));
} catch {
// Skip invalid lines
}
}
}
}
// Filter and validate task objects
return tasks.filter(t =>
t &&
typeof t === 'object' &&
(t.description || t.title || t.task) &&
(t.tool || t.mode || t.prompt_template)
).map(t => ({
description: t.description || t.title || t.task || 'Untitled task',
tool: t.tool || 'gemini',
mode: t.mode || 'write',
prompt_template: t.prompt_template || t.prompt || `Execute: ${t.description || t.title || t.task}`,
command: t.command,
on_error: t.on_error || 'continue'
}));
}