claude-code-workflow 6.3.36 → 6.3.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/workflow/lite-fix.md +108 -9
- package/.claude/skills/ccw-loop/README.md +303 -0
- package/.claude/skills/ccw-loop/SKILL.md +259 -0
- package/.claude/skills/ccw-loop/phases/actions/action-complete.md +320 -0
- package/.claude/skills/ccw-loop/phases/actions/action-debug-with-file.md +485 -0
- package/.claude/skills/ccw-loop/phases/actions/action-develop-with-file.md +365 -0
- package/.claude/skills/ccw-loop/phases/actions/action-init.md +200 -0
- package/.claude/skills/ccw-loop/phases/actions/action-menu.md +192 -0
- package/.claude/skills/ccw-loop/phases/actions/action-validate-with-file.md +307 -0
- package/.claude/skills/ccw-loop/phases/orchestrator.md +486 -0
- package/.claude/skills/ccw-loop/phases/state-schema.md +474 -0
- package/.claude/skills/ccw-loop/specs/action-catalog.md +300 -0
- package/.claude/skills/ccw-loop/specs/loop-requirements.md +192 -0
- package/.claude/skills/ccw-loop/templates/progress-template.md +175 -0
- package/.claude/skills/ccw-loop/templates/understanding-template.md +303 -0
- package/.claude/skills/ccw-loop/templates/validation-template.md +258 -0
- package/ccw/dist/cli.d.ts.map +1 -1
- package/ccw/dist/cli.js +8 -1
- package/ccw/dist/cli.js.map +1 -1
- package/ccw/dist/commands/cli.d.ts.map +1 -1
- package/ccw/dist/commands/cli.js +14 -1
- package/ccw/dist/commands/cli.js.map +1 -1
- package/ccw/dist/commands/loop.d.ts +10 -0
- package/ccw/dist/commands/loop.d.ts.map +1 -0
- package/ccw/dist/commands/loop.js +289 -0
- package/ccw/dist/commands/loop.js.map +1 -0
- package/ccw/dist/core/dashboard-generator.d.ts.map +1 -1
- package/ccw/dist/core/dashboard-generator.js +4 -1
- package/ccw/dist/core/dashboard-generator.js.map +1 -1
- package/ccw/dist/core/routes/claude-routes.d.ts.map +1 -1
- package/ccw/dist/core/routes/claude-routes.js +5 -3
- package/ccw/dist/core/routes/claude-routes.js.map +1 -1
- package/ccw/dist/core/routes/cli-routes.d.ts +6 -0
- package/ccw/dist/core/routes/cli-routes.d.ts.map +1 -1
- package/ccw/dist/core/routes/cli-routes.js +42 -13
- package/ccw/dist/core/routes/cli-routes.js.map +1 -1
- package/ccw/dist/core/routes/cli-settings-routes.d.ts.map +1 -1
- package/ccw/dist/core/routes/cli-settings-routes.js +44 -0
- package/ccw/dist/core/routes/cli-settings-routes.js.map +1 -1
- package/ccw/dist/core/routes/codexlens/semantic-handlers.d.ts.map +1 -1
- package/ccw/dist/core/routes/codexlens/semantic-handlers.js +3 -2
- package/ccw/dist/core/routes/codexlens/semantic-handlers.js.map +1 -1
- package/ccw/dist/core/routes/core-memory-routes.d.ts.map +1 -1
- package/ccw/dist/core/routes/core-memory-routes.js +4 -2
- package/ccw/dist/core/routes/core-memory-routes.js.map +1 -1
- package/ccw/dist/core/routes/files-routes.d.ts.map +1 -1
- package/ccw/dist/core/routes/files-routes.js +4 -2
- package/ccw/dist/core/routes/files-routes.js.map +1 -1
- package/ccw/dist/core/routes/loop-routes.d.ts +24 -0
- package/ccw/dist/core/routes/loop-routes.d.ts.map +1 -0
- package/ccw/dist/core/routes/loop-routes.js +334 -0
- package/ccw/dist/core/routes/loop-routes.js.map +1 -0
- package/ccw/dist/core/routes/loop-v2-routes.d.ts +35 -0
- package/ccw/dist/core/routes/loop-v2-routes.d.ts.map +1 -0
- package/ccw/dist/core/routes/loop-v2-routes.js +1208 -0
- package/ccw/dist/core/routes/loop-v2-routes.js.map +1 -0
- package/ccw/dist/core/routes/memory-routes.d.ts.map +1 -1
- package/ccw/dist/core/routes/memory-routes.js +2 -1
- package/ccw/dist/core/routes/memory-routes.js.map +1 -1
- package/ccw/dist/core/routes/task-routes.d.ts +12 -0
- package/ccw/dist/core/routes/task-routes.d.ts.map +1 -0
- package/ccw/dist/core/routes/task-routes.js +321 -0
- package/ccw/dist/core/routes/task-routes.js.map +1 -0
- package/ccw/dist/core/routes/test-loop-routes.d.ts +11 -0
- package/ccw/dist/core/routes/test-loop-routes.d.ts.map +1 -0
- package/ccw/dist/core/routes/test-loop-routes.js +298 -0
- package/ccw/dist/core/routes/test-loop-routes.js.map +1 -0
- package/ccw/dist/core/server.d.ts.map +1 -1
- package/ccw/dist/core/server.js +43 -3
- package/ccw/dist/core/server.js.map +1 -1
- package/ccw/dist/core/websocket.d.ts +59 -0
- package/ccw/dist/core/websocket.d.ts.map +1 -1
- package/ccw/dist/core/websocket.js +34 -0
- package/ccw/dist/core/websocket.js.map +1 -1
- package/ccw/dist/tools/claude-cli-tools.d.ts +40 -0
- package/ccw/dist/tools/claude-cli-tools.d.ts.map +1 -1
- package/ccw/dist/tools/claude-cli-tools.js +119 -0
- package/ccw/dist/tools/claude-cli-tools.js.map +1 -1
- package/ccw/dist/tools/loop-manager.d.ts +84 -0
- package/ccw/dist/tools/loop-manager.d.ts.map +1 -0
- package/ccw/dist/tools/loop-manager.js +425 -0
- package/ccw/dist/tools/loop-manager.js.map +1 -0
- package/ccw/dist/tools/loop-state-manager.d.ts +47 -0
- package/ccw/dist/tools/loop-state-manager.d.ts.map +1 -0
- package/ccw/dist/tools/loop-state-manager.js +149 -0
- package/ccw/dist/tools/loop-state-manager.js.map +1 -0
- package/ccw/dist/tools/loop-task-manager.d.ts +138 -0
- package/ccw/dist/tools/loop-task-manager.d.ts.map +1 -0
- package/ccw/dist/tools/loop-task-manager.js +270 -0
- package/ccw/dist/tools/loop-task-manager.js.map +1 -0
- package/ccw/dist/types/index.d.ts +1 -0
- package/ccw/dist/types/index.d.ts.map +1 -1
- package/ccw/dist/types/index.js +1 -0
- package/ccw/dist/types/index.js.map +1 -1
- package/ccw/dist/types/loop.d.ts +257 -0
- package/ccw/dist/types/loop.d.ts.map +1 -0
- package/ccw/dist/types/loop.js +17 -0
- package/ccw/dist/types/loop.js.map +1 -0
- package/ccw/src/cli.ts +9 -1
- package/ccw/src/commands/cli.ts +14 -1
- package/ccw/src/commands/loop.ts +344 -0
- package/ccw/src/core/dashboard-generator.ts +4 -1
- package/ccw/src/core/routes/claude-routes.ts +5 -3
- package/ccw/src/core/routes/cli-routes.ts +47 -15
- package/ccw/src/core/routes/cli-settings-routes.ts +47 -0
- package/ccw/src/core/routes/codexlens/semantic-handlers.ts +3 -2
- package/ccw/src/core/routes/core-memory-routes.ts +4 -2
- package/ccw/src/core/routes/files-routes.ts +4 -2
- package/ccw/src/core/routes/loop-routes.ts +386 -0
- package/ccw/src/core/routes/loop-v2-routes.ts +1412 -0
- package/ccw/src/core/routes/memory-routes.ts +2 -1
- package/ccw/src/core/routes/task-routes.ts +361 -0
- package/ccw/src/core/routes/test-loop-routes.ts +312 -0
- package/ccw/src/core/server.ts +44 -3
- package/ccw/src/core/websocket.ts +104 -0
- package/ccw/src/templates/dashboard-css/12-cli-legacy.css +56 -0
- package/ccw/src/templates/dashboard-css/33-cli-stream-viewer.css +55 -0
- package/ccw/src/templates/dashboard-css/36-loop-monitor.css +1896 -0
- package/ccw/src/templates/dashboard-css/36-loop-monitor.css.backup +1877 -0
- package/ccw/src/templates/dashboard-js/components/cli-status.js +64 -3
- package/ccw/src/templates/dashboard-js/components/cli-stream-viewer.js +251 -110
- package/ccw/src/templates/dashboard-js/components/navigation.js +10 -0
- package/ccw/src/templates/dashboard-js/components/notifications.js +16 -0
- package/ccw/src/templates/dashboard-js/i18n.js +475 -1
- package/ccw/src/templates/dashboard-js/views/cli-manager.js +3 -2
- package/ccw/src/templates/dashboard-js/views/loop-monitor.js +3244 -0
- package/ccw/src/templates/dashboard.html +20 -2
- package/ccw/src/tools/claude-cli-tools.ts +143 -0
- package/ccw/src/tools/loop-manager.ts +519 -0
- package/ccw/src/tools/loop-state-manager.ts +173 -0
- package/ccw/src/tools/loop-task-manager.ts +380 -0
- package/ccw/src/types/index.ts +1 -0
- package/ccw/src/types/loop.ts +316 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop V2 Routes Module
|
|
3
|
+
* CCW Loop System - Simplified HTTP API endpoints for Dashboard
|
|
4
|
+
* Provides simplified loop CRUD operations independent of task files
|
|
5
|
+
*
|
|
6
|
+
* Loop Endpoints:
|
|
7
|
+
* - GET /api/loops/v2 - List all loops with pagination
|
|
8
|
+
* - POST /api/loops/v2 - Create loop with {title, description, max_iterations}
|
|
9
|
+
* - GET /api/loops/v2/:loopId - Get loop details
|
|
10
|
+
* - PUT /api/loops/v2/:loopId - Update loop metadata (title, description, max_iterations, tags, priority, notes)
|
|
11
|
+
* - PATCH /api/loops/v2/:loopId/status - Quick status update with {status}
|
|
12
|
+
* - DELETE /api/loops/v2/:loopId - Delete loop
|
|
13
|
+
* - POST /api/loops/v2/:loopId/start - Start loop execution
|
|
14
|
+
* - POST /api/loops/v2/:loopId/pause - Pause loop
|
|
15
|
+
* - POST /api/loops/v2/:loopId/resume - Resume loop
|
|
16
|
+
* - POST /api/loops/v2/:loopId/stop - Stop loop
|
|
17
|
+
*
|
|
18
|
+
* Task Management Endpoints:
|
|
19
|
+
* - POST /api/loops/v2/:loopId/tasks - Add task to loop
|
|
20
|
+
* - GET /api/loops/v2/:loopId/tasks - List all tasks for loop
|
|
21
|
+
* - PUT /api/loops/v2/tasks/:taskId - Update task (requires loop_id in body)
|
|
22
|
+
* - DELETE /api/loops/v2/tasks/:taskId - Delete task (requires loop_id query param)
|
|
23
|
+
* - PUT /api/loops/v2/:loopId/tasks/reorder - Reorder tasks with {ordered_task_ids: string[]}
|
|
24
|
+
*
|
|
25
|
+
* Advanced Task Features:
|
|
26
|
+
* - POST /api/loops/v2/:loopId/import - Import tasks from issue with {issue_id}
|
|
27
|
+
* - POST /api/loops/v2/:loopId/generate - Generate tasks via Gemini with {tool?, count?}
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { join } from 'path';
|
|
31
|
+
import { randomBytes } from 'crypto';
|
|
32
|
+
import type { RouteContext } from './types.js';
|
|
33
|
+
import { LoopStatus } from '../../types/loop.js';
|
|
34
|
+
import type { LoopState } from '../../types/loop.js';
|
|
35
|
+
import { TaskStorageManager, type TaskCreateRequest, type TaskUpdateRequest, type TaskReorderRequest } from '../../tools/loop-task-manager.js';
|
|
36
|
+
import { executeCliTool } from '../../tools/cli-executor.js';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* V2 Loop Create Request
|
|
40
|
+
*/
|
|
41
|
+
interface V2LoopCreateRequest {
|
|
42
|
+
title: string;
|
|
43
|
+
description?: string;
|
|
44
|
+
max_iterations?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* V2 Loop Update Request (extended)
|
|
49
|
+
*/
|
|
50
|
+
interface V2LoopUpdateRequest {
|
|
51
|
+
// Basic fields
|
|
52
|
+
title?: string;
|
|
53
|
+
description?: string;
|
|
54
|
+
max_iterations?: number;
|
|
55
|
+
|
|
56
|
+
// Extended metadata fields
|
|
57
|
+
tags?: string[];
|
|
58
|
+
priority?: 'low' | 'medium' | 'high';
|
|
59
|
+
notes?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* V2 Loop Storage Format (simplified, independent of task files)
|
|
64
|
+
*/
|
|
65
|
+
interface V2LoopStorage {
|
|
66
|
+
loop_id: string;
|
|
67
|
+
title: string;
|
|
68
|
+
description: string;
|
|
69
|
+
max_iterations: number;
|
|
70
|
+
status: LoopStatus;
|
|
71
|
+
current_iteration: number;
|
|
72
|
+
created_at: string;
|
|
73
|
+
updated_at: string;
|
|
74
|
+
completed_at?: string;
|
|
75
|
+
failure_reason?: string;
|
|
76
|
+
|
|
77
|
+
// Extended metadata fields
|
|
78
|
+
tags?: string[];
|
|
79
|
+
priority?: 'low' | 'medium' | 'high';
|
|
80
|
+
notes?: string;
|
|
81
|
+
|
|
82
|
+
// Tasks stored in separate tasks.jsonl file
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Handle V2 loop routes
|
|
87
|
+
* @returns true if route was handled, false otherwise
|
|
88
|
+
*/
|
|
89
|
+
export async function handleLoopV2Routes(ctx: RouteContext): Promise<boolean> {
|
|
90
|
+
const { pathname, req, res, initialPath, handlePostRequest, url, broadcastToClients } = ctx;
|
|
91
|
+
|
|
92
|
+
// Get workflow directory from initialPath
|
|
93
|
+
const workflowDir = initialPath || process.cwd();
|
|
94
|
+
const loopDir = join(workflowDir, '.workflow', '.loop');
|
|
95
|
+
|
|
96
|
+
// Helper to broadcast loop state updates
|
|
97
|
+
const broadcastStateUpdate = (loopId: string, status: LoopStatus): void => {
|
|
98
|
+
try {
|
|
99
|
+
broadcastToClients({
|
|
100
|
+
type: 'LOOP_STATE_UPDATE',
|
|
101
|
+
loop_id: loopId,
|
|
102
|
+
status: status as 'created' | 'running' | 'paused' | 'completed' | 'failed',
|
|
103
|
+
updated_at: new Date().toISOString()
|
|
104
|
+
});
|
|
105
|
+
} catch (error) {
|
|
106
|
+
// Silently ignore broadcast errors
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Helper to generate loop ID
|
|
111
|
+
const generateLoopId = (): string => {
|
|
112
|
+
const timestamp = new Date().toISOString().replace(/[-:]/g, '').split('.')[0];
|
|
113
|
+
const random = randomBytes(4).toString('hex');
|
|
114
|
+
return `loop-v2-${timestamp}-${random}`;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Helper to read loop storage
|
|
118
|
+
const readLoopStorage = async (loopId: string): Promise<V2LoopStorage | null> => {
|
|
119
|
+
const { readFile } = await import('fs/promises');
|
|
120
|
+
const { existsSync } = await import('fs');
|
|
121
|
+
const filePath = join(loopDir, `${loopId}.json`);
|
|
122
|
+
|
|
123
|
+
if (!existsSync(filePath)) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const content = await readFile(filePath, 'utf-8');
|
|
129
|
+
return JSON.parse(content) as V2LoopStorage;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Helper to write loop storage
|
|
136
|
+
const writeLoopStorage = async (loop: V2LoopStorage): Promise<void> => {
|
|
137
|
+
const { writeFile, mkdir } = await import('fs/promises');
|
|
138
|
+
const { existsSync } = await import('fs');
|
|
139
|
+
|
|
140
|
+
if (!existsSync(loopDir)) {
|
|
141
|
+
await mkdir(loopDir, { recursive: true });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const filePath = join(loopDir, `${loop.loop_id}.json`);
|
|
145
|
+
await writeFile(filePath, JSON.stringify(loop, null, 2), 'utf-8');
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Helper to delete loop storage
|
|
149
|
+
const deleteLoopStorage = async (loopId: string): Promise<void> => {
|
|
150
|
+
const { unlink } = await import('fs/promises');
|
|
151
|
+
const { existsSync } = await import('fs');
|
|
152
|
+
const filePath = join(loopDir, `${loopId}.json`);
|
|
153
|
+
|
|
154
|
+
if (existsSync(filePath)) {
|
|
155
|
+
await unlink(filePath);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Also delete tasks.jsonl if exists
|
|
159
|
+
const tasksPath = join(loopDir, `${loopId}.tasks.jsonl`);
|
|
160
|
+
if (existsSync(tasksPath)) {
|
|
161
|
+
await unlink(tasksPath).catch(() => {});
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Helper to list all loops
|
|
166
|
+
const listLoops = async (): Promise<V2LoopStorage[]> => {
|
|
167
|
+
const { readdir } = await import('fs/promises');
|
|
168
|
+
const { existsSync } = await import('fs');
|
|
169
|
+
|
|
170
|
+
if (!existsSync(loopDir)) {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const files = await readdir(loopDir);
|
|
175
|
+
const loopFiles = files.filter(f => f.startsWith('loop-v2-') && f.endsWith('.json'));
|
|
176
|
+
|
|
177
|
+
const loops: V2LoopStorage[] = [];
|
|
178
|
+
for (const file of loopFiles) {
|
|
179
|
+
const loopId = file.replace('.json', '');
|
|
180
|
+
const loop = await readLoopStorage(loopId);
|
|
181
|
+
if (loop) {
|
|
182
|
+
loops.push(loop);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return loops;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// ==== EXACT PATH ROUTES ====
|
|
190
|
+
|
|
191
|
+
// POST /api/loops/v2 - Create loop with simplified fields
|
|
192
|
+
if (pathname === '/api/loops/v2' && req.method === 'POST') {
|
|
193
|
+
handlePostRequest(req, res, async (body) => {
|
|
194
|
+
const { title, description, max_iterations } = body as V2LoopCreateRequest;
|
|
195
|
+
|
|
196
|
+
// Validation
|
|
197
|
+
if (!title || typeof title !== 'string' || title.trim().length === 0) {
|
|
198
|
+
return { success: false, error: 'title is required and must be non-empty', status: 400 };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (description !== undefined && typeof description !== 'string') {
|
|
202
|
+
return { success: false, error: 'description must be a string', status: 400 };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (max_iterations !== undefined && (typeof max_iterations !== 'number' || max_iterations < 1)) {
|
|
206
|
+
return { success: false, error: 'max_iterations must be a positive number', status: 400 };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const loopId = generateLoopId();
|
|
211
|
+
const now = new Date().toISOString();
|
|
212
|
+
|
|
213
|
+
const loop: V2LoopStorage = {
|
|
214
|
+
loop_id: loopId,
|
|
215
|
+
title: title.trim(),
|
|
216
|
+
description: description?.trim() || '',
|
|
217
|
+
max_iterations: max_iterations || 10,
|
|
218
|
+
status: LoopStatus.CREATED,
|
|
219
|
+
current_iteration: 0,
|
|
220
|
+
created_at: now,
|
|
221
|
+
updated_at: now
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
await writeLoopStorage(loop);
|
|
225
|
+
|
|
226
|
+
// Broadcast creation
|
|
227
|
+
broadcastStateUpdate(loopId, LoopStatus.CREATED);
|
|
228
|
+
|
|
229
|
+
return { success: true, data: loop };
|
|
230
|
+
} catch (error) {
|
|
231
|
+
return { success: false, error: (error as Error).message, status: 500 };
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// GET /api/loops/v2 - List all loops with pagination
|
|
238
|
+
if (pathname === '/api/loops/v2' && req.method === 'GET') {
|
|
239
|
+
try {
|
|
240
|
+
const loops = await listLoops();
|
|
241
|
+
|
|
242
|
+
// Parse query params for pagination and filtering
|
|
243
|
+
const searchParams = url?.searchParams;
|
|
244
|
+
let filteredLoops = loops;
|
|
245
|
+
|
|
246
|
+
// Filter by status
|
|
247
|
+
const statusFilter = searchParams?.get('status');
|
|
248
|
+
if (statusFilter && statusFilter !== 'all') {
|
|
249
|
+
filteredLoops = filteredLoops.filter(l => l.status === statusFilter);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Sort by updated_at (most recent first)
|
|
253
|
+
filteredLoops.sort((a, b) =>
|
|
254
|
+
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
// Parse pagination params
|
|
258
|
+
const limit = parseInt(searchParams?.get('limit') || '50', 10);
|
|
259
|
+
const offset = parseInt(searchParams?.get('offset') || '0', 10);
|
|
260
|
+
|
|
261
|
+
// Apply pagination
|
|
262
|
+
const paginatedLoops = filteredLoops.slice(offset, offset + limit);
|
|
263
|
+
|
|
264
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
265
|
+
res.end(JSON.stringify({
|
|
266
|
+
success: true,
|
|
267
|
+
data: paginatedLoops,
|
|
268
|
+
total: filteredLoops.length,
|
|
269
|
+
limit,
|
|
270
|
+
offset,
|
|
271
|
+
hasMore: offset + limit < filteredLoops.length,
|
|
272
|
+
timestamp: new Date().toISOString()
|
|
273
|
+
}));
|
|
274
|
+
return true;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
277
|
+
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ==== NESTED PATH ROUTES (more specific patterns first) ====
|
|
283
|
+
|
|
284
|
+
// POST /api/loops/v2/:loopId/start - Start loop execution
|
|
285
|
+
if (pathname.match(/\/api\/loops\/v2\/[^/]+\/start$/) && req.method === 'POST') {
|
|
286
|
+
const loopId = pathname.split('/').slice(-2)[0];
|
|
287
|
+
if (!loopId || !isValidId(loopId)) {
|
|
288
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
289
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const loop = await readLoopStorage(loopId);
|
|
295
|
+
if (!loop) {
|
|
296
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
297
|
+
res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Can only start created or paused loops
|
|
302
|
+
if (!['created', 'paused'].includes(loop.status.toLowerCase())) {
|
|
303
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
304
|
+
res.end(JSON.stringify({
|
|
305
|
+
success: false,
|
|
306
|
+
error: `Cannot start loop with status: ${loop.status}`
|
|
307
|
+
}));
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Update loop status
|
|
312
|
+
loop.status = LoopStatus.RUNNING;
|
|
313
|
+
loop.updated_at = new Date().toISOString();
|
|
314
|
+
await writeLoopStorage(loop);
|
|
315
|
+
|
|
316
|
+
// Broadcast state update
|
|
317
|
+
broadcastStateUpdate(loopId, LoopStatus.RUNNING);
|
|
318
|
+
|
|
319
|
+
// Trigger ccw-loop skill execution (non-blocking)
|
|
320
|
+
// The skill will check status before each action and exit gracefully on pause/stop
|
|
321
|
+
executeCliTool({
|
|
322
|
+
tool: 'claude',
|
|
323
|
+
prompt: `/ccw-loop --loop-id ${loopId} --auto`,
|
|
324
|
+
mode: 'write',
|
|
325
|
+
workingDir: workflowDir
|
|
326
|
+
}).catch((error) => {
|
|
327
|
+
// Log error but don't fail the start request
|
|
328
|
+
console.error(`Failed to trigger ccw-loop skill for ${loopId}:`, error);
|
|
329
|
+
// Update loop status to failed
|
|
330
|
+
readLoopStorage(loopId).then(async (failedLoop) => {
|
|
331
|
+
if (failedLoop) {
|
|
332
|
+
failedLoop.status = LoopStatus.FAILED;
|
|
333
|
+
failedLoop.failure_reason = `Skill execution failed: ${error.message}`;
|
|
334
|
+
failedLoop.completed_at = new Date().toISOString();
|
|
335
|
+
await writeLoopStorage(failedLoop);
|
|
336
|
+
broadcastStateUpdate(loopId, LoopStatus.FAILED);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
342
|
+
res.end(JSON.stringify({ success: true, data: loop, message: 'Loop started' }));
|
|
343
|
+
return true;
|
|
344
|
+
} catch (error) {
|
|
345
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
346
|
+
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// POST /api/loops/v2/:loopId/pause - Pause loop
|
|
352
|
+
if (pathname.match(/\/api\/loops\/v2\/[^/]+\/pause$/) && req.method === 'POST') {
|
|
353
|
+
const loopId = pathname.split('/').slice(-2)[0];
|
|
354
|
+
if (!loopId || !isValidId(loopId)) {
|
|
355
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
356
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
const loop = await readLoopStorage(loopId);
|
|
362
|
+
if (!loop) {
|
|
363
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
364
|
+
res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Can only pause running loops
|
|
369
|
+
if (loop.status !== LoopStatus.RUNNING) {
|
|
370
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
371
|
+
res.end(JSON.stringify({
|
|
372
|
+
success: false,
|
|
373
|
+
error: `Cannot pause loop with status: ${loop.status}`
|
|
374
|
+
}));
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
loop.status = LoopStatus.PAUSED;
|
|
379
|
+
loop.updated_at = new Date().toISOString();
|
|
380
|
+
await writeLoopStorage(loop);
|
|
381
|
+
|
|
382
|
+
broadcastStateUpdate(loopId, LoopStatus.PAUSED);
|
|
383
|
+
|
|
384
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
385
|
+
res.end(JSON.stringify({ success: true, data: loop, message: 'Loop paused' }));
|
|
386
|
+
return true;
|
|
387
|
+
} catch (error) {
|
|
388
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
389
|
+
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// POST /api/loops/v2/:loopId/resume - Resume loop
|
|
395
|
+
if (pathname.match(/\/api\/loops\/v2\/[^/]+\/resume$/) && req.method === 'POST') {
|
|
396
|
+
const loopId = pathname.split('/').slice(-2)[0];
|
|
397
|
+
if (!loopId || !isValidId(loopId)) {
|
|
398
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
399
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
const loop = await readLoopStorage(loopId);
|
|
405
|
+
if (!loop) {
|
|
406
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
407
|
+
res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Can only resume paused loops
|
|
412
|
+
if (loop.status !== LoopStatus.PAUSED) {
|
|
413
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
414
|
+
res.end(JSON.stringify({
|
|
415
|
+
success: false,
|
|
416
|
+
error: `Cannot resume loop with status: ${loop.status}`
|
|
417
|
+
}));
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
loop.status = LoopStatus.RUNNING;
|
|
422
|
+
loop.updated_at = new Date().toISOString();
|
|
423
|
+
await writeLoopStorage(loop);
|
|
424
|
+
|
|
425
|
+
broadcastStateUpdate(loopId, LoopStatus.RUNNING);
|
|
426
|
+
|
|
427
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
428
|
+
res.end(JSON.stringify({ success: true, data: loop, message: 'Loop resumed' }));
|
|
429
|
+
return true;
|
|
430
|
+
} catch (error) {
|
|
431
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
432
|
+
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// POST /api/loops/v2/:loopId/stop - Stop loop
|
|
438
|
+
if (pathname.match(/\/api\/loops\/v2\/[^/]+\/stop$/) && req.method === 'POST') {
|
|
439
|
+
const loopId = pathname.split('/').slice(-2)[0];
|
|
440
|
+
if (!loopId || !isValidId(loopId)) {
|
|
441
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
442
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
const loop = await readLoopStorage(loopId);
|
|
448
|
+
if (!loop) {
|
|
449
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
450
|
+
res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Can only stop running or paused loops
|
|
455
|
+
if (![LoopStatus.RUNNING, LoopStatus.PAUSED, LoopStatus.CREATED].includes(loop.status)) {
|
|
456
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
457
|
+
res.end(JSON.stringify({
|
|
458
|
+
success: false,
|
|
459
|
+
error: `Cannot stop loop with status: ${loop.status}`
|
|
460
|
+
}));
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
loop.status = LoopStatus.FAILED;
|
|
465
|
+
loop.failure_reason = 'Manually stopped by user';
|
|
466
|
+
loop.completed_at = new Date().toISOString();
|
|
467
|
+
loop.updated_at = loop.completed_at;
|
|
468
|
+
await writeLoopStorage(loop);
|
|
469
|
+
|
|
470
|
+
broadcastStateUpdate(loopId, LoopStatus.FAILED);
|
|
471
|
+
|
|
472
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
473
|
+
res.end(JSON.stringify({ success: true, data: loop, message: 'Loop stopped' }));
|
|
474
|
+
return true;
|
|
475
|
+
} catch (error) {
|
|
476
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
477
|
+
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ==== SINGLE PARAM ROUTES (must come after nested routes) ====
|
|
483
|
+
|
|
484
|
+
// GET /api/loops/v2/:loopId - Get loop details
|
|
485
|
+
if (pathname.match(/^\/api\/loops\/v2\/[^/]+$/) && req.method === 'GET') {
|
|
486
|
+
const loopId = pathname.split('/').pop();
|
|
487
|
+
if (!loopId || !isValidId(loopId)) {
|
|
488
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
489
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
const loop = await readLoopStorage(loopId);
|
|
495
|
+
if (!loop) {
|
|
496
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
497
|
+
res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
502
|
+
res.end(JSON.stringify({ success: true, data: loop }));
|
|
503
|
+
return true;
|
|
504
|
+
} catch (error) {
|
|
505
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
506
|
+
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// PUT /api/loops/v2/:loopId - Update loop metadata
|
|
512
|
+
if (pathname.match(/^\/api\/loops\/v2\/[^/]+$/) && req.method === 'PUT') {
|
|
513
|
+
const loopId = pathname.split('/').pop();
|
|
514
|
+
if (!loopId || !isValidId(loopId)) {
|
|
515
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
516
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
handlePostRequest(req, res, async (body) => {
|
|
521
|
+
const { title, description, max_iterations, tags, priority, notes } = body as V2LoopUpdateRequest;
|
|
522
|
+
|
|
523
|
+
try {
|
|
524
|
+
const loop = await readLoopStorage(loopId);
|
|
525
|
+
if (!loop) {
|
|
526
|
+
return { success: false, error: 'Loop not found', status: 404 };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Can only update created or paused loops
|
|
530
|
+
if (![LoopStatus.CREATED, LoopStatus.PAUSED, LoopStatus.FAILED, LoopStatus.COMPLETED].includes(loop.status)) {
|
|
531
|
+
return { success: false, error: `Cannot update loop with status: ${loop.status}`, status: 400 };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Validate and apply updates
|
|
535
|
+
if (title !== undefined) {
|
|
536
|
+
if (typeof title !== 'string' || title.trim().length === 0) {
|
|
537
|
+
return { success: false, error: 'title must be a non-empty string', status: 400 };
|
|
538
|
+
}
|
|
539
|
+
loop.title = title.trim();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (description !== undefined) {
|
|
543
|
+
if (typeof description !== 'string') {
|
|
544
|
+
return { success: false, error: 'description must be a string', status: 400 };
|
|
545
|
+
}
|
|
546
|
+
loop.description = description.trim();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (max_iterations !== undefined) {
|
|
550
|
+
if (typeof max_iterations !== 'number' || max_iterations < 1) {
|
|
551
|
+
return { success: false, error: 'max_iterations must be a positive number', status: 400 };
|
|
552
|
+
}
|
|
553
|
+
loop.max_iterations = max_iterations;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Extended metadata fields
|
|
557
|
+
if (tags !== undefined) {
|
|
558
|
+
if (!Array.isArray(tags) || !tags.every(t => typeof t === 'string')) {
|
|
559
|
+
return { success: false, error: 'tags must be an array of strings', status: 400 };
|
|
560
|
+
}
|
|
561
|
+
loop.tags = tags;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (priority !== undefined) {
|
|
565
|
+
if (!['low', 'medium', 'high'].includes(priority)) {
|
|
566
|
+
return { success: false, error: 'priority must be one of: low, medium, high', status: 400 };
|
|
567
|
+
}
|
|
568
|
+
loop.priority = priority;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (notes !== undefined) {
|
|
572
|
+
if (typeof notes !== 'string') {
|
|
573
|
+
return { success: false, error: 'notes must be a string', status: 400 };
|
|
574
|
+
}
|
|
575
|
+
loop.notes = notes.trim();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
loop.updated_at = new Date().toISOString();
|
|
579
|
+
await writeLoopStorage(loop);
|
|
580
|
+
|
|
581
|
+
broadcastStateUpdate(loopId, loop.status);
|
|
582
|
+
|
|
583
|
+
return { success: true, data: loop };
|
|
584
|
+
} catch (error) {
|
|
585
|
+
return { success: false, error: (error as Error).message, status: 500 };
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// PATCH /api/loops/v2/:loopId/status - Quick status update
|
|
592
|
+
if (pathname.match(/\/api\/loops\/v2\/[^/]+\/status$/) && req.method === 'PATCH') {
|
|
593
|
+
const loopId = pathname.split('/').slice(-2)[0];
|
|
594
|
+
if (!loopId || !isValidId(loopId)) {
|
|
595
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
596
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
handlePostRequest(req, res, async (body) => {
|
|
601
|
+
const { status } = body as { status?: string };
|
|
602
|
+
|
|
603
|
+
if (!status || typeof status !== 'string') {
|
|
604
|
+
return { success: false, error: 'status is required', status: 400 };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (!Object.values(LoopStatus).includes(status as LoopStatus)) {
|
|
608
|
+
return { success: false, error: `Invalid status: ${status}`, status: 400 };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
try {
|
|
612
|
+
const loop = await readLoopStorage(loopId);
|
|
613
|
+
if (!loop) {
|
|
614
|
+
return { success: false, error: 'Loop not found', status: 404 };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
loop.status = status as LoopStatus;
|
|
618
|
+
loop.updated_at = new Date().toISOString();
|
|
619
|
+
|
|
620
|
+
if (status === LoopStatus.COMPLETED && !loop.completed_at) {
|
|
621
|
+
loop.completed_at = new Date().toISOString();
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
await writeLoopStorage(loop);
|
|
625
|
+
|
|
626
|
+
broadcastStateUpdate(loopId, loop.status);
|
|
627
|
+
|
|
628
|
+
return { success: true, data: loop };
|
|
629
|
+
} catch (error) {
|
|
630
|
+
return { success: false, error: (error as Error).message, status: 500 };
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
return true;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// DELETE /api/loops/v2/:loopId - Delete loop
|
|
637
|
+
if (pathname.match(/^\/api\/loops\/v2\/[^/]+$/) && req.method === 'DELETE') {
|
|
638
|
+
const loopId = pathname.split('/').pop();
|
|
639
|
+
if (!loopId || !isValidId(loopId)) {
|
|
640
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
641
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
|
642
|
+
return true;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
try {
|
|
646
|
+
const loop = await readLoopStorage(loopId);
|
|
647
|
+
if (!loop) {
|
|
648
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
649
|
+
res.end(JSON.stringify({ success: false, error: 'Loop not found' }));
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Cannot delete running loops
|
|
654
|
+
if (loop.status === LoopStatus.RUNNING) {
|
|
655
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
656
|
+
res.end(JSON.stringify({
|
|
657
|
+
success: false,
|
|
658
|
+
error: 'Cannot delete running loop. Stop it first.'
|
|
659
|
+
}));
|
|
660
|
+
return true;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
await deleteLoopStorage(loopId);
|
|
664
|
+
|
|
665
|
+
// Broadcast deletion
|
|
666
|
+
try {
|
|
667
|
+
broadcastToClients({
|
|
668
|
+
type: 'LOOP_DELETED',
|
|
669
|
+
loop_id: loopId
|
|
670
|
+
});
|
|
671
|
+
} catch {
|
|
672
|
+
// Ignore broadcast errors
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
676
|
+
res.end(JSON.stringify({ success: true, message: 'Loop deleted' }));
|
|
677
|
+
return true;
|
|
678
|
+
} catch (error) {
|
|
679
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
680
|
+
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ==== TASK MANAGEMENT ENDPOINTS ====
|
|
686
|
+
|
|
687
|
+
// Helper to create TaskStorageManager instance
|
|
688
|
+
const createTaskManager = (): TaskStorageManager => {
|
|
689
|
+
return new TaskStorageManager(workflowDir);
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
// POST /api/loops/v2/:loopId/tasks - Add task to loop
|
|
693
|
+
if (pathname.match(/\/api\/loops\/v2\/[^/]+\/tasks$/) && req.method === 'POST') {
|
|
694
|
+
const loopId = pathname.split('/').slice(-2)[0];
|
|
695
|
+
if (!loopId || !isValidId(loopId)) {
|
|
696
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
697
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
|
698
|
+
return true;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
handlePostRequest(req, res, async (body) => {
|
|
702
|
+
const { description, tool, mode, prompt_template, command, on_error } = body as TaskCreateRequest;
|
|
703
|
+
|
|
704
|
+
// Validation
|
|
705
|
+
if (!description || typeof description !== 'string' || description.trim().length === 0) {
|
|
706
|
+
return { success: false, error: 'description is required', status: 400 };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (!tool || typeof tool !== 'string') {
|
|
710
|
+
return { success: false, error: 'tool is required', status: 400 };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const validTools = ['bash', 'gemini', 'codex', 'qwen', 'claude'];
|
|
714
|
+
if (!validTools.includes(tool)) {
|
|
715
|
+
return { success: false, error: `tool must be one of: ${validTools.join(', ')}`, status: 400 };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (!mode || typeof mode !== 'string') {
|
|
719
|
+
return { success: false, error: 'mode is required', status: 400 };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const validModes = ['analysis', 'write', 'review'];
|
|
723
|
+
if (!validModes.includes(mode)) {
|
|
724
|
+
return { success: false, error: `mode must be one of: ${validModes.join(', ')}`, status: 400 };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (!prompt_template || typeof prompt_template !== 'string' || prompt_template.trim().length === 0) {
|
|
728
|
+
return { success: false, error: 'prompt_template is required', status: 400 };
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
try {
|
|
732
|
+
const taskManager = createTaskManager();
|
|
733
|
+
const task = await taskManager.addTask(loopId, {
|
|
734
|
+
description: description.trim(),
|
|
735
|
+
tool,
|
|
736
|
+
mode,
|
|
737
|
+
prompt_template: prompt_template.trim(),
|
|
738
|
+
command,
|
|
739
|
+
on_error
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
// Broadcast task added
|
|
743
|
+
try {
|
|
744
|
+
broadcastToClients({
|
|
745
|
+
type: 'TASK_ADDED',
|
|
746
|
+
loop_id: loopId,
|
|
747
|
+
task_id: task.task_id,
|
|
748
|
+
task: task
|
|
749
|
+
});
|
|
750
|
+
} catch {
|
|
751
|
+
// Ignore broadcast errors
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
return { success: true, data: task };
|
|
755
|
+
} catch (error) {
|
|
756
|
+
return { success: false, error: (error as Error).message, status: 500 };
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
return true;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// GET /api/loops/v2/:loopId/tasks - List all tasks for loop
|
|
763
|
+
if (pathname.match(/\/api\/loops\/v2\/[^/]+\/tasks$/) && req.method === 'GET') {
|
|
764
|
+
const loopId = pathname.split('/').slice(-2)[0];
|
|
765
|
+
if (!loopId || !isValidId(loopId)) {
|
|
766
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
767
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
|
768
|
+
return true;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
try {
|
|
772
|
+
const taskManager = createTaskManager();
|
|
773
|
+
const tasks = await taskManager.getTasks(loopId);
|
|
774
|
+
|
|
775
|
+
// Sort by order
|
|
776
|
+
tasks.sort((a, b) => a.order - b.order);
|
|
777
|
+
|
|
778
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
779
|
+
res.end(JSON.stringify({
|
|
780
|
+
success: true,
|
|
781
|
+
data: tasks,
|
|
782
|
+
total: tasks.length,
|
|
783
|
+
loop_id: loopId,
|
|
784
|
+
timestamp: new Date().toISOString()
|
|
785
|
+
}));
|
|
786
|
+
return true;
|
|
787
|
+
} catch (error) {
|
|
788
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
789
|
+
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
|
790
|
+
return true;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// GET /api/loops/v2/tasks/:taskId - Get single task (taskId lookup)
|
|
795
|
+
if (pathname.match(/^\/api\/loops\/v2\/tasks\/[^/]+$/) && req.method === 'GET') {
|
|
796
|
+
const taskId = pathname.split('/').pop();
|
|
797
|
+
if (!taskId || !isValidId(taskId)) {
|
|
798
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
799
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid task ID format' }));
|
|
800
|
+
return true;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
try {
|
|
804
|
+
const taskManager = createTaskManager();
|
|
805
|
+
// Get all loops and search for the task
|
|
806
|
+
const loops = await listLoops();
|
|
807
|
+
let foundTask = null;
|
|
808
|
+
let foundLoopId = null;
|
|
809
|
+
|
|
810
|
+
for (const loop of loops) {
|
|
811
|
+
const loopId = loop.loop_id;
|
|
812
|
+
try {
|
|
813
|
+
const tasks = await taskManager.getTasks(loopId);
|
|
814
|
+
const task = tasks.find(t => t.task_id === taskId);
|
|
815
|
+
if (task) {
|
|
816
|
+
foundTask = task;
|
|
817
|
+
foundLoopId = loopId;
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
820
|
+
} catch {
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (!foundTask) {
|
|
826
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
827
|
+
res.end(JSON.stringify({ success: false, error: 'Task not found' }));
|
|
828
|
+
return true;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
832
|
+
res.end(JSON.stringify({
|
|
833
|
+
success: true,
|
|
834
|
+
data: { ...foundTask, loop_id: foundLoopId },
|
|
835
|
+
timestamp: new Date().toISOString()
|
|
836
|
+
}));
|
|
837
|
+
return true;
|
|
838
|
+
} catch (error) {
|
|
839
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
840
|
+
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
|
841
|
+
return true;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// PUT /api/loops/v2/tasks/:taskId - Update task (taskId lookup)
|
|
846
|
+
if (pathname.match(/^\/api\/loops\/v2\/tasks\/[^/]+$/) && req.method === 'PUT') {
|
|
847
|
+
const taskId = pathname.split('/').pop();
|
|
848
|
+
if (!taskId || !isValidId(taskId)) {
|
|
849
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
850
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid task ID format' }));
|
|
851
|
+
return true;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
handlePostRequest(req, res, async (body) => {
|
|
855
|
+
const { loop_id, description, tool, mode, prompt_template, command, on_error } = body as TaskUpdateRequest & { loop_id?: string };
|
|
856
|
+
|
|
857
|
+
if (!loop_id || typeof loop_id !== 'string') {
|
|
858
|
+
return { success: false, error: 'loop_id is required', status: 400 };
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (!isValidId(loop_id)) {
|
|
862
|
+
return { success: false, error: 'Invalid loop_id format', status: 400 };
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
try {
|
|
866
|
+
const taskManager = createTaskManager();
|
|
867
|
+
const updatedTask = await taskManager.updateTask(loop_id, taskId, {
|
|
868
|
+
description,
|
|
869
|
+
tool,
|
|
870
|
+
mode,
|
|
871
|
+
prompt_template,
|
|
872
|
+
command,
|
|
873
|
+
on_error
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
if (!updatedTask) {
|
|
877
|
+
return { success: false, error: 'Task not found', status: 404 };
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Broadcast task updated
|
|
881
|
+
try {
|
|
882
|
+
broadcastToClients({
|
|
883
|
+
type: 'TASK_UPDATED',
|
|
884
|
+
loop_id: loop_id,
|
|
885
|
+
task_id: taskId,
|
|
886
|
+
task: updatedTask
|
|
887
|
+
});
|
|
888
|
+
} catch {
|
|
889
|
+
// Ignore broadcast errors
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
return { success: true, data: updatedTask };
|
|
893
|
+
} catch (error) {
|
|
894
|
+
return { success: false, error: (error as Error).message, status: 500 };
|
|
895
|
+
}
|
|
896
|
+
});
|
|
897
|
+
return true;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// DELETE /api/loops/v2/tasks/:taskId - Delete task (taskId lookup)
|
|
901
|
+
if (pathname.match(/^\/api\/loops\/v2\/tasks\/[^/]+$/) && req.method === 'DELETE') {
|
|
902
|
+
const taskId = pathname.split('/').pop();
|
|
903
|
+
if (!taskId || !isValidId(taskId)) {
|
|
904
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
905
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid task ID format' }));
|
|
906
|
+
return true;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Get loop_id from query parameter
|
|
910
|
+
const urlObj = new URL(req.url || '', `http://localhost`);
|
|
911
|
+
const loopId = urlObj.searchParams.get('loop_id');
|
|
912
|
+
|
|
913
|
+
if (!loopId || !isValidId(loopId)) {
|
|
914
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
915
|
+
res.end(JSON.stringify({ success: false, error: 'loop_id query parameter is required' }));
|
|
916
|
+
return true;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
try {
|
|
920
|
+
const taskManager = createTaskManager();
|
|
921
|
+
const deleted = await taskManager.deleteTask(loopId, taskId);
|
|
922
|
+
|
|
923
|
+
if (!deleted) {
|
|
924
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
925
|
+
res.end(JSON.stringify({ success: false, error: 'Task not found' }));
|
|
926
|
+
return true;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Broadcast task deleted
|
|
930
|
+
try {
|
|
931
|
+
broadcastToClients({
|
|
932
|
+
type: 'TASK_DELETED',
|
|
933
|
+
loop_id: loopId,
|
|
934
|
+
task_id: taskId
|
|
935
|
+
});
|
|
936
|
+
} catch {
|
|
937
|
+
// Ignore broadcast errors
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
941
|
+
res.end(JSON.stringify({ success: true, message: 'Task deleted' }));
|
|
942
|
+
return true;
|
|
943
|
+
} catch (error) {
|
|
944
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
945
|
+
res.end(JSON.stringify({ success: false, error: (error as Error).message }));
|
|
946
|
+
return true;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// PUT /api/loops/v2/:loopId/tasks/reorder - Reorder tasks
|
|
951
|
+
if (pathname.match(/\/api\/loops\/v2\/[^/]+\/tasks\/reorder$/) && req.method === 'PUT') {
|
|
952
|
+
const loopId = pathname.split('/').slice(-3)[0];
|
|
953
|
+
if (!loopId || !isValidId(loopId)) {
|
|
954
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
955
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
|
956
|
+
return true;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
handlePostRequest(req, res, async (body) => {
|
|
960
|
+
const { ordered_task_ids } = body as TaskReorderRequest;
|
|
961
|
+
|
|
962
|
+
if (!ordered_task_ids || !Array.isArray(ordered_task_ids)) {
|
|
963
|
+
return { success: false, error: 'ordered_task_ids must be an array', status: 400 };
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
if (ordered_task_ids.length === 0) {
|
|
967
|
+
return { success: false, error: 'ordered_task_ids cannot be empty', status: 400 };
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
try {
|
|
971
|
+
const taskManager = createTaskManager();
|
|
972
|
+
const reorderedTasks = await taskManager.reorderTasks(loopId, { ordered_task_ids });
|
|
973
|
+
|
|
974
|
+
// Broadcast tasks reordered
|
|
975
|
+
try {
|
|
976
|
+
broadcastToClients({
|
|
977
|
+
type: 'TASK_REORDERED',
|
|
978
|
+
loop_id: loopId,
|
|
979
|
+
ordered_task_ids: ordered_task_ids,
|
|
980
|
+
tasks: reorderedTasks
|
|
981
|
+
});
|
|
982
|
+
} catch {
|
|
983
|
+
// Ignore broadcast errors
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
return { success: true, data: reorderedTasks };
|
|
987
|
+
} catch (error) {
|
|
988
|
+
return { success: false, error: (error as Error).message, status: 500 };
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
return true;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// ==== ADVANCED TASK FEATURES ====
|
|
995
|
+
|
|
996
|
+
// POST /api/loops/v2/:loopId/import - Import tasks from issue
|
|
997
|
+
if (pathname.match(/\/api\/loops\/v2\/[^/]+\/import$/) && req.method === 'POST') {
|
|
998
|
+
const loopId = pathname.split('/').slice(-2)[0];
|
|
999
|
+
if (!loopId || !isValidId(loopId)) {
|
|
1000
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1001
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
|
1002
|
+
return true;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
handlePostRequest(req, res, async (body) => {
|
|
1006
|
+
const { issue_id } = body as { issue_id?: string };
|
|
1007
|
+
|
|
1008
|
+
if (!issue_id || typeof issue_id !== 'string') {
|
|
1009
|
+
return { success: false, error: 'issue_id is required', status: 400 };
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
try {
|
|
1013
|
+
// Fetch issue data from issue-manager
|
|
1014
|
+
const { readFile } = await import('fs/promises');
|
|
1015
|
+
const { existsSync } = await import('fs');
|
|
1016
|
+
const issuesDir = join(workflowDir, '.workflow', 'issues');
|
|
1017
|
+
const issuesPath = join(issuesDir, 'issues.jsonl');
|
|
1018
|
+
|
|
1019
|
+
let issueData: any = null;
|
|
1020
|
+
|
|
1021
|
+
// Try reading from active issues
|
|
1022
|
+
if (existsSync(issuesPath)) {
|
|
1023
|
+
const content = await readFile(issuesPath, 'utf-8');
|
|
1024
|
+
const issues = content.split('\n').filter(line => line.trim()).map(line => JSON.parse(line));
|
|
1025
|
+
issueData = issues.find((i: any) => i.id === issue_id);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Try reading from history if not found
|
|
1029
|
+
if (!issueData) {
|
|
1030
|
+
const historyPath = join(issuesDir, 'issue-history.jsonl');
|
|
1031
|
+
if (existsSync(historyPath)) {
|
|
1032
|
+
const content = await readFile(historyPath, 'utf-8');
|
|
1033
|
+
const historyIssues = content.split('\n').filter(line => line.trim()).map(line => JSON.parse(line));
|
|
1034
|
+
issueData = historyIssues.find((i: any) => i.id === issue_id);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
if (!issueData) {
|
|
1039
|
+
return { success: false, error: `Issue ${issue_id} not found`, status: 404 };
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Load solutions to get bound solution tasks
|
|
1043
|
+
const solutionsPath = join(issuesDir, 'solutions', `${issue_id}.jsonl`);
|
|
1044
|
+
let tasksToImport: any[] = [];
|
|
1045
|
+
|
|
1046
|
+
if (existsSync(solutionsPath)) {
|
|
1047
|
+
const solutionsContent = await readFile(solutionsPath, 'utf-8');
|
|
1048
|
+
const solutions = solutionsContent.split('\n').filter(line => line.trim()).map(line => JSON.parse(line));
|
|
1049
|
+
|
|
1050
|
+
// Get tasks from bound solution
|
|
1051
|
+
const boundSolution = solutions.find((s: any) => s.id === issueData.bound_solution_id) ||
|
|
1052
|
+
solutions.find((s: any) => s.is_bound) ||
|
|
1053
|
+
solutions[0];
|
|
1054
|
+
|
|
1055
|
+
if (boundSolution?.tasks) {
|
|
1056
|
+
tasksToImport = boundSolution.tasks;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
if (tasksToImport.length === 0) {
|
|
1061
|
+
return { success: false, error: 'No tasks found in issue. Bind a solution with tasks first.', status: 400 };
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Broadcast import start
|
|
1065
|
+
broadcastToClients({
|
|
1066
|
+
type: 'LOOP_TASK_IMPORT_PROGRESS',
|
|
1067
|
+
loop_id: loopId,
|
|
1068
|
+
payload: {
|
|
1069
|
+
stage: 'starting',
|
|
1070
|
+
total: tasksToImport.length,
|
|
1071
|
+
imported: 0
|
|
1072
|
+
}
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
const taskManager = createTaskManager();
|
|
1076
|
+
const createdTasks: any[] = [];
|
|
1077
|
+
|
|
1078
|
+
// Convert issue tasks to loop tasks
|
|
1079
|
+
for (let i = 0; i < tasksToImport.length; i++) {
|
|
1080
|
+
const issueTask = tasksToImport[i];
|
|
1081
|
+
|
|
1082
|
+
// Map issue task fields to loop task fields
|
|
1083
|
+
const taskRequest: TaskCreateRequest = {
|
|
1084
|
+
description: issueTask.description || issueTask.title || `Task ${i + 1}`,
|
|
1085
|
+
tool: mapIssueToolToLoopTool(issueTask.tool) || 'gemini',
|
|
1086
|
+
mode: mapIssueModeToLoopMode(issueTask.mode) || 'write',
|
|
1087
|
+
prompt_template: issueTask.prompt_template || issueTask.prompt || `Execute: ${issueTask.description || issueTask.title}`,
|
|
1088
|
+
command: issueTask.command,
|
|
1089
|
+
on_error: mapIssueOnError(issueTask.on_error)
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
const task = await taskManager.addTask(loopId, taskRequest);
|
|
1093
|
+
createdTasks.push(task);
|
|
1094
|
+
|
|
1095
|
+
// Broadcast progress
|
|
1096
|
+
broadcastToClients({
|
|
1097
|
+
type: 'LOOP_TASK_IMPORT_PROGRESS',
|
|
1098
|
+
loop_id: loopId,
|
|
1099
|
+
payload: {
|
|
1100
|
+
stage: 'importing',
|
|
1101
|
+
total: tasksToImport.length,
|
|
1102
|
+
imported: i + 1,
|
|
1103
|
+
current_task: task
|
|
1104
|
+
}
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Broadcast completion
|
|
1109
|
+
broadcastToClients({
|
|
1110
|
+
type: 'LOOP_TASK_IMPORT_COMPLETE',
|
|
1111
|
+
loop_id: loopId,
|
|
1112
|
+
payload: {
|
|
1113
|
+
total: tasksToImport.length,
|
|
1114
|
+
imported: createdTasks.length,
|
|
1115
|
+
tasks: createdTasks
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
return {
|
|
1120
|
+
success: true,
|
|
1121
|
+
data: createdTasks,
|
|
1122
|
+
message: `Imported ${createdTasks.length} tasks from issue ${issue_id}`
|
|
1123
|
+
};
|
|
1124
|
+
} catch (error) {
|
|
1125
|
+
return { success: false, error: (error as Error).message, status: 500 };
|
|
1126
|
+
}
|
|
1127
|
+
});
|
|
1128
|
+
return true;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// POST /api/loops/v2/:loopId/generate - Generate tasks via Gemini
|
|
1132
|
+
if (pathname.match(/\/api\/loops\/v2\/[^/]+\/generate$/) && req.method === 'POST') {
|
|
1133
|
+
const loopId = pathname.split('/').slice(-2)[0];
|
|
1134
|
+
if (!loopId || !isValidId(loopId)) {
|
|
1135
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1136
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid loop ID format' }));
|
|
1137
|
+
return true;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
handlePostRequest(req, res, async (body) => {
|
|
1141
|
+
const { tool = 'gemini', count } = body as { tool?: string; count?: number };
|
|
1142
|
+
|
|
1143
|
+
try {
|
|
1144
|
+
// Get loop details for context
|
|
1145
|
+
const loop = await readLoopStorage(loopId);
|
|
1146
|
+
if (!loop) {
|
|
1147
|
+
return { success: false, error: 'Loop not found', status: 404 };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Broadcast generation start
|
|
1151
|
+
broadcastToClients({
|
|
1152
|
+
type: 'LOOP_TASK_GENERATION_PROGRESS',
|
|
1153
|
+
loop_id: loopId,
|
|
1154
|
+
payload: {
|
|
1155
|
+
stage: 'analyzing',
|
|
1156
|
+
message: 'Analyzing loop description...'
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
// Build generation prompt
|
|
1161
|
+
const generatePrompt = `PURPOSE: Generate ${count || 5} specific tasks for loop execution
|
|
1162
|
+
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.
|
|
1163
|
+
MODE: analysis
|
|
1164
|
+
CONTEXT: Loop title: ${loop.title}
|
|
1165
|
+
Loop description: ${loop.description || 'No description provided'}
|
|
1166
|
+
Max iterations: ${loop.max_iterations}
|
|
1167
|
+
EXPECTED: Return a JSON array of tasks with this exact structure:
|
|
1168
|
+
[
|
|
1169
|
+
{
|
|
1170
|
+
"description": "Clear task description",
|
|
1171
|
+
"tool": "gemini|codex|qwen|bash",
|
|
1172
|
+
"mode": "analysis|write|review",
|
|
1173
|
+
"prompt_template": "PURPOSE: ... TASK: ... MODE: analysis CONTEXT: @**/* EXPECTED: ...",
|
|
1174
|
+
"on_error": "continue|pause|fail_fast"
|
|
1175
|
+
}
|
|
1176
|
+
]
|
|
1177
|
+
CONSTRAINTS: Generate ${count || 5} tasks | Use gemini for AI tasks | Use bash for CLI commands | Include error handling strategy`;
|
|
1178
|
+
|
|
1179
|
+
// Call CLI with gemini to generate tasks
|
|
1180
|
+
let generatedTasks: any[] = [];
|
|
1181
|
+
let outputBuffer = '';
|
|
1182
|
+
|
|
1183
|
+
const result = await executeCliTool({
|
|
1184
|
+
tool: tool === 'codex' || tool === 'qwen' || tool === 'gemini' ? tool : 'gemini',
|
|
1185
|
+
prompt: generatePrompt,
|
|
1186
|
+
mode: 'analysis',
|
|
1187
|
+
format: 'plain',
|
|
1188
|
+
cd: workflowDir,
|
|
1189
|
+
timeout: 120000, // 2 minutes timeout
|
|
1190
|
+
stream: true
|
|
1191
|
+
}, (unit) => {
|
|
1192
|
+
// Collect output
|
|
1193
|
+
outputBuffer += unit.content;
|
|
1194
|
+
|
|
1195
|
+
// Broadcast partial output for progress
|
|
1196
|
+
broadcastToClients({
|
|
1197
|
+
type: 'LOOP_TASK_GENERATION_PROGRESS',
|
|
1198
|
+
loop_id: loopId,
|
|
1199
|
+
payload: {
|
|
1200
|
+
stage: 'generating',
|
|
1201
|
+
message: 'Generating tasks...',
|
|
1202
|
+
output: unit.content
|
|
1203
|
+
}
|
|
1204
|
+
});
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
if (!result.success) {
|
|
1208
|
+
return { success: false, error: 'Failed to generate tasks via CLI', status: 500 };
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// Parse generated tasks from CLI output
|
|
1212
|
+
generatedTasks = parseGeneratedTasks(outputBuffer);
|
|
1213
|
+
|
|
1214
|
+
if (generatedTasks.length === 0) {
|
|
1215
|
+
return {
|
|
1216
|
+
success: false,
|
|
1217
|
+
error: 'No valid tasks generated. Check CLI output for details.',
|
|
1218
|
+
status: 500,
|
|
1219
|
+
output: outputBuffer
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// Broadcast import start
|
|
1224
|
+
broadcastToClients({
|
|
1225
|
+
type: 'LOOP_TASK_GENERATION_PROGRESS',
|
|
1226
|
+
loop_id: loopId,
|
|
1227
|
+
payload: {
|
|
1228
|
+
stage: 'importing',
|
|
1229
|
+
message: `Importing ${generatedTasks.length} generated tasks...`,
|
|
1230
|
+
total: generatedTasks.length,
|
|
1231
|
+
imported: 0
|
|
1232
|
+
}
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
const taskManager = createTaskManager();
|
|
1236
|
+
const createdTasks: any[] = [];
|
|
1237
|
+
|
|
1238
|
+
// Add generated tasks to loop
|
|
1239
|
+
for (let i = 0; i < generatedTasks.length; i++) {
|
|
1240
|
+
const genTask = generatedTasks[i];
|
|
1241
|
+
|
|
1242
|
+
const taskRequest: TaskCreateRequest = {
|
|
1243
|
+
description: genTask.description || `Generated Task ${i + 1}`,
|
|
1244
|
+
tool: validateTool(genTask.tool) ? genTask.tool : 'gemini',
|
|
1245
|
+
mode: validateMode(genTask.mode) ? genTask.mode : 'write',
|
|
1246
|
+
prompt_template: genTask.prompt_template || `Execute task: ${genTask.description}`,
|
|
1247
|
+
command: genTask.command,
|
|
1248
|
+
on_error: validateOnError(genTask.on_error) ? genTask.on_error : 'continue'
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
const task = await taskManager.addTask(loopId, taskRequest);
|
|
1252
|
+
createdTasks.push(task);
|
|
1253
|
+
|
|
1254
|
+
// Broadcast progress
|
|
1255
|
+
broadcastToClients({
|
|
1256
|
+
type: 'LOOP_TASK_GENERATION_PROGRESS',
|
|
1257
|
+
loop_id: loopId,
|
|
1258
|
+
payload: {
|
|
1259
|
+
stage: 'importing',
|
|
1260
|
+
message: `Importing task ${i + 1}/${generatedTasks.length}...`,
|
|
1261
|
+
total: generatedTasks.length,
|
|
1262
|
+
imported: i + 1,
|
|
1263
|
+
current_task: task
|
|
1264
|
+
}
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// Broadcast completion
|
|
1269
|
+
broadcastToClients({
|
|
1270
|
+
type: 'LOOP_TASK_GENERATION_COMPLETE',
|
|
1271
|
+
loop_id: loopId,
|
|
1272
|
+
payload: {
|
|
1273
|
+
total: generatedTasks.length,
|
|
1274
|
+
imported: createdTasks.length,
|
|
1275
|
+
tasks: createdTasks
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
return {
|
|
1280
|
+
success: true,
|
|
1281
|
+
data: createdTasks,
|
|
1282
|
+
message: `Generated and imported ${createdTasks.length} tasks`
|
|
1283
|
+
};
|
|
1284
|
+
} catch (error) {
|
|
1285
|
+
return { success: false, error: (error as Error).message, status: 500 };
|
|
1286
|
+
}
|
|
1287
|
+
});
|
|
1288
|
+
return true;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
return false;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/**
|
|
1295
|
+
* Sanitize ID parameter to prevent path traversal attacks
|
|
1296
|
+
* @returns true if valid, false if invalid
|
|
1297
|
+
*/
|
|
1298
|
+
function isValidId(id: string): boolean {
|
|
1299
|
+
if (!id) return false;
|
|
1300
|
+
// Block path traversal attempts and null bytes
|
|
1301
|
+
if (id.includes('/') || id.includes('\\') || id === '..' || id === '.') return false;
|
|
1302
|
+
if (id.includes('\0')) return false;
|
|
1303
|
+
return true;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/**
|
|
1307
|
+
* Map issue tool to loop tool
|
|
1308
|
+
*/
|
|
1309
|
+
function mapIssueToolToLoopTool(tool: any): 'bash' | 'gemini' | 'codex' | 'qwen' | 'claude' | null {
|
|
1310
|
+
const validTools = ['bash', 'gemini', 'codex', 'qwen', 'claude'];
|
|
1311
|
+
if (validTools.includes(tool)) return tool as any;
|
|
1312
|
+
// Map aliases
|
|
1313
|
+
if (tool === 'ccw') return 'gemini';
|
|
1314
|
+
if (tool === 'ai') return 'gemini';
|
|
1315
|
+
return null;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* Map issue mode to loop mode
|
|
1320
|
+
*/
|
|
1321
|
+
function mapIssueModeToLoopMode(mode: any): 'analysis' | 'write' | 'review' | null {
|
|
1322
|
+
const validModes = ['analysis', 'write', 'review'];
|
|
1323
|
+
if (validModes.includes(mode)) return mode as any;
|
|
1324
|
+
// Map aliases
|
|
1325
|
+
if (mode === 'read') return 'analysis';
|
|
1326
|
+
if (mode === 'create' || mode === 'modify') return 'write';
|
|
1327
|
+
return null;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/**
|
|
1331
|
+
* Map issue on_error value
|
|
1332
|
+
*/
|
|
1333
|
+
function mapIssueOnError(onError: any): 'continue' | 'pause' | 'fail_fast' | undefined {
|
|
1334
|
+
const validValues = ['continue', 'pause', 'fail_fast'];
|
|
1335
|
+
if (validValues.includes(onError)) return onError as any;
|
|
1336
|
+
// Map aliases
|
|
1337
|
+
if (onError === 'stop') return 'pause';
|
|
1338
|
+
if (onError === 'abort') return 'fail_fast';
|
|
1339
|
+
return undefined;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* Validate tool value
|
|
1344
|
+
*/
|
|
1345
|
+
function validateTool(tool: any): boolean {
|
|
1346
|
+
const validTools = ['bash', 'gemini', 'codex', 'qwen', 'claude'];
|
|
1347
|
+
return validTools.includes(tool);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* Validate mode value
|
|
1352
|
+
*/
|
|
1353
|
+
function validateMode(mode: any): boolean {
|
|
1354
|
+
const validModes = ['analysis', 'write', 'review'];
|
|
1355
|
+
return validModes.includes(mode);
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/**
|
|
1359
|
+
* Validate on_error value
|
|
1360
|
+
*/
|
|
1361
|
+
function validateOnError(onError: any): boolean {
|
|
1362
|
+
const validValues = ['continue', 'pause', 'fail_fast'];
|
|
1363
|
+
return validValues.includes(onError);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/**
|
|
1367
|
+
* Parse generated tasks from CLI output
|
|
1368
|
+
* Extracts JSON array from output, handles various response formats
|
|
1369
|
+
*/
|
|
1370
|
+
function parseGeneratedTasks(output: string): any[] {
|
|
1371
|
+
let tasks: any[] = [];
|
|
1372
|
+
|
|
1373
|
+
// Try to find JSON array in output
|
|
1374
|
+
const jsonMatch = output.match(/\[[\s\S]*\]/);
|
|
1375
|
+
if (jsonMatch) {
|
|
1376
|
+
try {
|
|
1377
|
+
tasks = JSON.parse(jsonMatch[0]);
|
|
1378
|
+
} catch {
|
|
1379
|
+
// Invalid JSON, try alternative parsing
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// If no valid JSON array found, try parsing line by line
|
|
1384
|
+
if (tasks.length === 0) {
|
|
1385
|
+
const lines = output.split('\n');
|
|
1386
|
+
for (const line of lines) {
|
|
1387
|
+
const trimmed = line.trim();
|
|
1388
|
+
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
|
1389
|
+
try {
|
|
1390
|
+
tasks.push(JSON.parse(trimmed));
|
|
1391
|
+
} catch {
|
|
1392
|
+
// Skip invalid lines
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// Filter and validate task objects
|
|
1399
|
+
return tasks.filter(t =>
|
|
1400
|
+
t &&
|
|
1401
|
+
typeof t === 'object' &&
|
|
1402
|
+
(t.description || t.title || t.task) &&
|
|
1403
|
+
(t.tool || t.mode || t.prompt_template)
|
|
1404
|
+
).map(t => ({
|
|
1405
|
+
description: t.description || t.title || t.task || 'Untitled task',
|
|
1406
|
+
tool: t.tool || 'gemini',
|
|
1407
|
+
mode: t.mode || 'write',
|
|
1408
|
+
prompt_template: t.prompt_template || t.prompt || `Execute: ${t.description || t.title || t.task}`,
|
|
1409
|
+
command: t.command,
|
|
1410
|
+
on_error: t.on_error || 'continue'
|
|
1411
|
+
}));
|
|
1412
|
+
}
|