mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
feat: Add comprehensive tests for CCW Loop System flow state
- Implemented loop control tasks in JSON format for testing. - Created comprehensive test scripts for loop flow and standalone tests. - Developed a shell script to automate the testing of the entire loop system flow, including mock endpoints and state transitions. - Added error handling and execution history tests to ensure robustness. - Established variable substitution and success condition evaluations in tests. - Set up cleanup and workspace management for test environments.
This commit is contained in:
@@ -99,7 +99,10 @@ const MODULE_CSS_FILES = [
|
||||
'29-help.css',
|
||||
'30-core-memory.css',
|
||||
'31-api-settings.css',
|
||||
'34-discovery.css'
|
||||
'32-issue-manager.css',
|
||||
'33-cli-stream-viewer.css',
|
||||
'34-discovery.css',
|
||||
'36-loop-monitor.css'
|
||||
];
|
||||
|
||||
const MODULE_FILES = [
|
||||
|
||||
@@ -60,9 +60,35 @@ interface ActiveExecution {
|
||||
startTime: number;
|
||||
output: string;
|
||||
status: 'running' | 'completed' | 'error';
|
||||
completedTimestamp?: number; // When execution completed (for 5-minute retention)
|
||||
}
|
||||
|
||||
const activeExecutions = new Map<string, ActiveExecution>();
|
||||
const EXECUTION_RETENTION_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Cleanup stale completed executions older than retention period
|
||||
* Runs periodically to prevent memory buildup
|
||||
*/
|
||||
export function cleanupStaleExecutions(): void {
|
||||
const now = Date.now();
|
||||
const staleIds: string[] = [];
|
||||
|
||||
for (const [id, exec] of activeExecutions.entries()) {
|
||||
if (exec.completedTimestamp && (now - exec.completedTimestamp) > EXECUTION_RETENTION_MS) {
|
||||
staleIds.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
staleIds.forEach(id => {
|
||||
activeExecutions.delete(id);
|
||||
console.log(`[ActiveExec] Cleaned up stale execution: ${id}`);
|
||||
});
|
||||
|
||||
if (staleIds.length > 0) {
|
||||
console.log(`[ActiveExec] Cleaned up ${staleIds.length} stale execution(s), remaining: ${activeExecutions.size}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active CLI executions
|
||||
@@ -113,19 +139,12 @@ export function updateActiveExecution(event: {
|
||||
activeExec.output += output;
|
||||
}
|
||||
} else if (type === 'completed') {
|
||||
// Mark as completed instead of immediately deleting
|
||||
// Keep execution visible for 5 minutes to allow page refreshes to see it
|
||||
// Mark as completed with timestamp for retention-based cleanup
|
||||
const activeExec = activeExecutions.get(executionId);
|
||||
if (activeExec) {
|
||||
activeExec.status = success ? 'completed' : 'error';
|
||||
|
||||
// Auto-cleanup after 5 minutes
|
||||
setTimeout(() => {
|
||||
activeExecutions.delete(executionId);
|
||||
console.log(`[ActiveExec] Auto-cleaned completed execution: ${executionId}`);
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
console.log(`[ActiveExec] Marked as ${activeExec.status}, will auto-clean in 5 minutes`);
|
||||
activeExec.completedTimestamp = Date.now();
|
||||
console.log(`[ActiveExec] Marked as ${activeExec.status}, retained for ${EXECUTION_RETENTION_MS / 1000}s`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +158,10 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
|
||||
// API: Get Active CLI Executions (for state recovery)
|
||||
if (pathname === '/api/cli/active' && req.method === 'GET') {
|
||||
const executions = getActiveExecutions();
|
||||
const executions = getActiveExecutions().map(exec => ({
|
||||
...exec,
|
||||
isComplete: exec.status !== 'running'
|
||||
}));
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ executions }));
|
||||
return true;
|
||||
@@ -664,8 +686,13 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
});
|
||||
});
|
||||
|
||||
// Remove from active executions on completion
|
||||
activeExecutions.delete(executionId);
|
||||
// Mark as completed with timestamp for retention-based cleanup (not immediate delete)
|
||||
const activeExec = activeExecutions.get(executionId);
|
||||
if (activeExec) {
|
||||
activeExec.status = result.success ? 'completed' : 'error';
|
||||
activeExec.completedTimestamp = Date.now();
|
||||
console.log(`[ActiveExec] Direct execution ${executionId} marked as ${activeExec.status}, retained for ${EXECUTION_RETENTION_MS / 1000}s`);
|
||||
}
|
||||
|
||||
// Broadcast completion
|
||||
broadcastToClients({
|
||||
@@ -684,8 +711,13 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
};
|
||||
|
||||
} catch (error: unknown) {
|
||||
// Remove from active executions on error
|
||||
activeExecutions.delete(executionId);
|
||||
// Mark as completed with timestamp for retention-based cleanup (not immediate delete)
|
||||
const activeExec = activeExecutions.get(executionId);
|
||||
if (activeExec) {
|
||||
activeExec.status = 'error';
|
||||
activeExec.completedTimestamp = Date.now();
|
||||
console.log(`[ActiveExec] Direct execution ${executionId} marked as error, retained for ${EXECUTION_RETENTION_MS / 1000}s`);
|
||||
}
|
||||
|
||||
broadcastToClients({
|
||||
type: 'CLI_EXECUTION_ERROR',
|
||||
|
||||
1332
ccw/src/core/routes/loop-v2-routes.ts
Normal file
1332
ccw/src/core/routes/loop-v2-routes.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -152,6 +152,48 @@ export async function handleTaskRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/tasks/:taskId - Get single task
|
||||
const taskDetailMatch = pathname.match(/^\/api\/tasks\/([^\/]+)$/);
|
||||
if (taskDetailMatch && req.method === 'GET') {
|
||||
const taskId = decodeURIComponent(taskDetailMatch[1]);
|
||||
|
||||
// Sanitize taskId to prevent path traversal
|
||||
if (taskId.includes('/') || taskId.includes('\\') || taskId === '..' || taskId === '.') {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'Invalid task ID format' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const taskPath = join(taskDir, taskId + '.json');
|
||||
|
||||
if (!existsSync(taskPath)) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: 'Task not found: ' + taskId }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const content = await readFile(taskPath, 'utf-8');
|
||||
const task = JSON.parse(content) as Task;
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
task: task
|
||||
}
|
||||
}));
|
||||
return true;
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: false,
|
||||
error: (error as Error).message
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/tasks/validate - Validate task loop_control configuration
|
||||
if (pathname === '/api/tasks/validate' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { resolvePath, getRecentPaths, normalizePathForDisplay } from '../utils/p
|
||||
|
||||
// Import route handlers
|
||||
import { handleStatusRoutes } from './routes/status-routes.js';
|
||||
import { handleCliRoutes } from './routes/cli-routes.js';
|
||||
import { handleCliRoutes, cleanupStaleExecutions } from './routes/cli-routes.js';
|
||||
import { handleCliSettingsRoutes } from './routes/cli-settings-routes.js';
|
||||
import { handleMemoryRoutes } from './routes/memory-routes.js';
|
||||
import { handleCoreMemoryRoutes } from './routes/core-memory-routes.js';
|
||||
@@ -29,6 +29,7 @@ import { handleLiteLLMApiRoutes } from './routes/litellm-api-routes.js';
|
||||
import { handleNavStatusRoutes } from './routes/nav-status-routes.js';
|
||||
import { handleAuthRoutes } from './routes/auth-routes.js';
|
||||
import { handleLoopRoutes } from './routes/loop-routes.js';
|
||||
import { handleLoopV2Routes } from './routes/loop-v2-routes.js';
|
||||
import { handleTestLoopRoutes } from './routes/test-loop-routes.js';
|
||||
import { handleTaskRoutes } from './routes/task-routes.js';
|
||||
|
||||
@@ -568,7 +569,12 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
if (await handleCcwRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// Loop routes (/api/loops*)
|
||||
// Loop V2 routes (/api/loops/v2/*) - must be checked before v1
|
||||
if (pathname.startsWith('/api/loops/v2')) {
|
||||
if (await handleLoopV2Routes(routeContext)) return;
|
||||
}
|
||||
|
||||
// Loop V1 routes (/api/loops/*) - backward compatibility
|
||||
if (pathname.startsWith('/api/loops')) {
|
||||
if (await handleLoopRoutes(routeContext)) return;
|
||||
}
|
||||
@@ -717,6 +723,14 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
console.log(`WebSocket endpoint available at ws://${host}:${serverPort}/ws`);
|
||||
console.log(`Hook endpoint available at POST http://${host}:${serverPort}/api/hook`);
|
||||
|
||||
// Start periodic cleanup of stale CLI executions (every 2 minutes)
|
||||
const CLEANUP_INTERVAL_MS = 2 * 60 * 1000;
|
||||
const cleanupInterval = setInterval(cleanupStaleExecutions, CLEANUP_INTERVAL_MS);
|
||||
server.on('close', () => {
|
||||
clearInterval(cleanupInterval);
|
||||
console.log('[Server] Stopped CLI execution cleanup interval');
|
||||
});
|
||||
|
||||
// Start health check service for all enabled providers
|
||||
try {
|
||||
const healthCheckService = getHealthCheckService();
|
||||
|
||||
@@ -2,6 +2,41 @@
|
||||
* Legacy Container Styles (kept for compatibility)
|
||||
* ======================================== */
|
||||
|
||||
/* CLI Stream Recovery Badge Styles */
|
||||
.cli-stream-recovery-badge {
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: hsl(38 92% 50% / 0.15);
|
||||
color: hsl(38 92% 50%);
|
||||
border-radius: 9999px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
margin-left: 0.375rem;
|
||||
}
|
||||
|
||||
.cli-status-recovery-badge {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: hsl(38 92% 50% / 0.15);
|
||||
color: hsl(38 92% 50%);
|
||||
border-radius: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Tab styling for recovered sessions */
|
||||
.cli-stream-tab.recovered {
|
||||
border-color: hsl(38 92% 50% / 0.3);
|
||||
}
|
||||
|
||||
.cli-stream-tab.recovered .cli-stream-recovery-badge {
|
||||
background: hsl(38 92% 50% / 0.2);
|
||||
color: hsl(38 92% 55%);
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.cli-manager-container {
|
||||
display: flex;
|
||||
|
||||
@@ -161,6 +161,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
/* Isolate from parent transform to fix native tooltip positioning */
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.cli-stream-action-btn {
|
||||
@@ -196,6 +198,10 @@
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
/* Fix native tooltip positioning under transformed parent */
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.cli-stream-close-btn:hover {
|
||||
@@ -203,6 +209,49 @@
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
/* Icon-only action buttons (cleaner style matching close button) */
|
||||
.cli-stream-icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
/* Fix native tooltip positioning under transformed parent */
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
/* Create new stacking context to isolate from parent transform */
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.cli-stream-icon-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.cli-stream-icon-btn:hover {
|
||||
background: hsl(var(--hover));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.cli-stream-icon-btn:first-child:hover {
|
||||
/* Clear completed - green/success tint */
|
||||
background: hsl(142 76% 36% / 0.1);
|
||||
color: hsl(142 76% 36%);
|
||||
}
|
||||
|
||||
.cli-stream-icon-btn:nth-child(2):hover {
|
||||
/* Clear all - orange/warning tint */
|
||||
background: hsl(38 92% 50% / 0.1);
|
||||
color: hsl(38 92% 50%);
|
||||
}
|
||||
|
||||
/* ===== Tab Bar ===== */
|
||||
.cli-stream-tabs {
|
||||
display: flex;
|
||||
@@ -787,6 +836,12 @@
|
||||
animation: streamBadgePulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.cli-stream-badge.has-completed {
|
||||
display: flex;
|
||||
background: hsl(var(--muted) / 0.8);
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
@keyframes streamBadgePulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.15); }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1877
ccw/src/templates/dashboard-css/36-loop-monitor.css.backup
Normal file
1877
ccw/src/templates/dashboard-css/36-loop-monitor.css.backup
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ let streamScrollHandler = null; // Track scroll listener
|
||||
let streamStatusTimers = []; // Track status update timers
|
||||
|
||||
// ===== State Management =====
|
||||
let cliStreamExecutions = {}; // { executionId: { tool, mode, output, status, startTime, endTime } }
|
||||
let cliStreamExecutions = {}; // { executionId: { tool, mode, output, status, startTime, endTime, recovered } }
|
||||
let activeStreamTab = null;
|
||||
let autoScrollEnabled = true;
|
||||
let isCliStreamViewerOpen = false;
|
||||
@@ -18,116 +18,212 @@ let searchFilter = ''; // Search filter for output content
|
||||
|
||||
const MAX_OUTPUT_LINES = 5000; // Prevent memory issues
|
||||
|
||||
// ===== Sync State Management =====
|
||||
let syncPromise = null; // Track ongoing sync to prevent duplicates
|
||||
let syncTimeoutId = null; // Debounce timeout ID
|
||||
let lastSyncTime = 0; // Track last successful sync time
|
||||
const SYNC_DEBOUNCE_MS = 300; // Debounce delay for sync calls
|
||||
const SYNC_TIMEOUT_MS = 10000; // 10 second timeout for sync requests
|
||||
|
||||
// ===== State Synchronization =====
|
||||
/**
|
||||
* Sync active executions from server
|
||||
* Called on initialization to recover state when view is opened mid-execution
|
||||
* Also called on WebSocket reconnection to restore CLI viewer state
|
||||
*
|
||||
* Features:
|
||||
* - Debouncing: Prevents rapid successive sync calls
|
||||
* - Deduplication: Only one sync at a time
|
||||
* - Timeout handling: 10 second timeout for sync requests
|
||||
* - Recovery flag: Marks recovered sessions for visual indicator
|
||||
*/
|
||||
async function syncActiveExecutions() {
|
||||
// Only sync in server mode
|
||||
if (!window.SERVER_MODE) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/cli/active');
|
||||
if (!response.ok) return;
|
||||
// Deduplication: if a sync is already in progress, return that promise
|
||||
if (syncPromise) {
|
||||
console.log('[CLI Stream] Sync already in progress, skipping');
|
||||
return syncPromise;
|
||||
}
|
||||
|
||||
const { executions } = await response.json();
|
||||
if (!executions || executions.length === 0) return;
|
||||
// Clear any pending debounced sync
|
||||
if (syncTimeoutId) {
|
||||
clearTimeout(syncTimeoutId);
|
||||
syncTimeoutId = null;
|
||||
}
|
||||
|
||||
let needsUiUpdate = false;
|
||||
syncPromise = (async function() {
|
||||
try {
|
||||
// Create timeout promise
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Sync timeout')), SYNC_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
executions.forEach(exec => {
|
||||
const existing = cliStreamExecutions[exec.id];
|
||||
// Race between fetch and timeout
|
||||
const response = await Promise.race([
|
||||
fetch('/api/cli/active'),
|
||||
timeoutPromise
|
||||
]);
|
||||
|
||||
// Parse historical output from server
|
||||
const historicalLines = [];
|
||||
if (exec.output) {
|
||||
const lines = exec.output.split('\n');
|
||||
const startIndex = Math.max(0, lines.length - MAX_OUTPUT_LINES + 1);
|
||||
lines.slice(startIndex).forEach(line => {
|
||||
if (line.trim()) {
|
||||
historicalLines.push({
|
||||
type: 'stdout',
|
||||
content: line,
|
||||
timestamp: exec.startTime || Date.now()
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
// Already tracked by WebSocket events - merge historical output
|
||||
// Only prepend historical lines that are not already in the output
|
||||
// (WebSocket events only add NEW output, so historical output should come before)
|
||||
const existingContentSet = new Set(existing.output.map(o => o.content));
|
||||
const missingLines = historicalLines.filter(h => !existingContentSet.has(h.content));
|
||||
|
||||
if (missingLines.length > 0) {
|
||||
// Find the system start message index (skip it when prepending)
|
||||
const systemMsgIndex = existing.output.findIndex(o => o.type === 'system');
|
||||
const insertIndex = systemMsgIndex >= 0 ? systemMsgIndex + 1 : 0;
|
||||
|
||||
// Prepend missing historical lines after system message
|
||||
existing.output.splice(insertIndex, 0, ...missingLines);
|
||||
|
||||
// Trim if too long
|
||||
if (existing.output.length > MAX_OUTPUT_LINES) {
|
||||
existing.output = existing.output.slice(-MAX_OUTPUT_LINES);
|
||||
}
|
||||
|
||||
needsUiUpdate = true;
|
||||
console.log(`[CLI Stream] Merged ${missingLines.length} historical lines for ${exec.id}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
console.warn('[CLI Stream] Sync response not OK:', response.status);
|
||||
return;
|
||||
}
|
||||
|
||||
needsUiUpdate = true;
|
||||
const { executions } = await response.json();
|
||||
|
||||
// New execution - rebuild full state
|
||||
cliStreamExecutions[exec.id] = {
|
||||
tool: exec.tool || 'cli',
|
||||
mode: exec.mode || 'analysis',
|
||||
output: [],
|
||||
status: exec.status || 'running',
|
||||
startTime: exec.startTime || Date.now(),
|
||||
endTime: null
|
||||
};
|
||||
// Handle empty response gracefully
|
||||
if (!executions || executions.length === 0) {
|
||||
console.log('[CLI Stream] No active executions to sync');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add system start message
|
||||
cliStreamExecutions[exec.id].output.push({
|
||||
type: 'system',
|
||||
content: `[${new Date(exec.startTime).toLocaleTimeString()}] CLI execution started: ${exec.tool} (${exec.mode} mode)`,
|
||||
timestamp: exec.startTime
|
||||
let needsUiUpdate = false;
|
||||
const now = Date.now();
|
||||
lastSyncTime = now;
|
||||
|
||||
executions.forEach(exec => {
|
||||
const existing = cliStreamExecutions[exec.id];
|
||||
|
||||
// Parse historical output from server with type detection
|
||||
const historicalLines = [];
|
||||
if (exec.output) {
|
||||
const lines = exec.output.split('\n');
|
||||
const startIndex = Math.max(0, lines.length - MAX_OUTPUT_LINES + 1);
|
||||
lines.slice(startIndex).forEach(line => {
|
||||
if (line.trim()) {
|
||||
// Detect type from content prefix for proper formatting
|
||||
const parsed = parseMessageType(line);
|
||||
// Map parsed type to chunkType for rendering
|
||||
const typeMap = {
|
||||
system: 'system',
|
||||
thinking: 'thought',
|
||||
response: 'stdout',
|
||||
result: 'metadata',
|
||||
error: 'stderr',
|
||||
warning: 'stderr',
|
||||
info: 'metadata'
|
||||
};
|
||||
historicalLines.push({
|
||||
type: parsed.hasPrefix ? (typeMap[parsed.type] || 'stdout') : 'stdout',
|
||||
content: line, // Keep original content with prefix
|
||||
timestamp: exec.startTime || Date.now()
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
// Already tracked by WebSocket events - merge historical output
|
||||
// Only prepend historical lines that are not already in the output
|
||||
// (WebSocket events only add NEW output, so historical output should come before)
|
||||
const existingContentSet = new Set(existing.output.map(o => o.content));
|
||||
const missingLines = historicalLines.filter(h => !existingContentSet.has(h.content));
|
||||
|
||||
if (missingLines.length > 0) {
|
||||
// Find the system start message index (skip it when prepending)
|
||||
const systemMsgIndex = existing.output.findIndex(o => o.type === 'system');
|
||||
const insertIndex = systemMsgIndex >= 0 ? systemMsgIndex + 1 : 0;
|
||||
|
||||
// Prepend missing historical lines after system message
|
||||
existing.output.splice(insertIndex, 0, ...missingLines);
|
||||
|
||||
// Trim if too long
|
||||
if (existing.output.length > MAX_OUTPUT_LINES) {
|
||||
existing.output = existing.output.slice(-MAX_OUTPUT_LINES);
|
||||
}
|
||||
|
||||
needsUiUpdate = true;
|
||||
console.log(`[CLI Stream] Merged ${missingLines.length} historical lines for ${exec.id}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
needsUiUpdate = true;
|
||||
|
||||
// New execution - rebuild full state with recovered flag
|
||||
cliStreamExecutions[exec.id] = {
|
||||
tool: exec.tool || 'cli',
|
||||
mode: exec.mode || 'analysis',
|
||||
output: [],
|
||||
status: exec.status || 'running',
|
||||
startTime: exec.startTime || Date.now(),
|
||||
endTime: exec.status !== 'running' ? Date.now() : null,
|
||||
recovered: true // Mark as recovered for visual indicator
|
||||
};
|
||||
|
||||
// Add system start message
|
||||
cliStreamExecutions[exec.id].output.push({
|
||||
type: 'system',
|
||||
content: `[${new Date(exec.startTime).toLocaleTimeString()}] CLI execution started: ${exec.tool} (${exec.mode} mode)`,
|
||||
timestamp: exec.startTime
|
||||
});
|
||||
|
||||
// Add historical output
|
||||
cliStreamExecutions[exec.id].output.push(...historicalLines);
|
||||
|
||||
// Add recovery notice for completed executions
|
||||
if (exec.isComplete) {
|
||||
cliStreamExecutions[exec.id].output.push({
|
||||
type: 'system',
|
||||
content: `[Session recovered from server - ${exec.status}]`,
|
||||
timestamp: now
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add historical output
|
||||
cliStreamExecutions[exec.id].output.push(...historicalLines);
|
||||
});
|
||||
// Update UI if we recovered or merged any executions
|
||||
if (needsUiUpdate) {
|
||||
// Set active tab to first running execution, or first recovered if none running
|
||||
const runningExec = executions.find(e => e.status === 'running');
|
||||
if (runningExec && !activeStreamTab) {
|
||||
activeStreamTab = runningExec.id;
|
||||
} else if (!runningExec && executions.length > 0 && !activeStreamTab) {
|
||||
// If no running executions, select the first recovered one
|
||||
activeStreamTab = executions[0].id;
|
||||
}
|
||||
|
||||
// Update UI if we recovered or merged any executions
|
||||
if (needsUiUpdate) {
|
||||
// Set active tab to first running execution
|
||||
const runningExec = executions.find(e => e.status === 'running');
|
||||
if (runningExec && !activeStreamTab) {
|
||||
activeStreamTab = runningExec.id;
|
||||
renderStreamTabs();
|
||||
updateStreamBadge();
|
||||
|
||||
// If viewer is open, render content. If not, open it if we have any recovered executions.
|
||||
if (isCliStreamViewerOpen) {
|
||||
renderStreamContent(activeStreamTab);
|
||||
} else if (executions.length > 0) {
|
||||
// Automatically open the viewer if it's closed and we just synced any executions
|
||||
// (running or completed - user might refresh after completion to see the output)
|
||||
toggleCliStreamViewer();
|
||||
}
|
||||
}
|
||||
|
||||
renderStreamTabs();
|
||||
updateStreamBadge();
|
||||
|
||||
// If viewer is open, render content. If not, and there's a running execution, open it.
|
||||
if (isCliStreamViewerOpen) {
|
||||
renderStreamContent(activeStreamTab);
|
||||
} else if (executions.some(e => e.status === 'running')) {
|
||||
// Automatically open the viewer if it's closed and we just synced a running task
|
||||
toggleCliStreamViewer();
|
||||
console.log(`[CLI Stream] Synced ${executions.length} active execution(s)`);
|
||||
} catch (e) {
|
||||
if (e.message === 'Sync timeout') {
|
||||
console.warn('[CLI Stream] Sync request timed out after', SYNC_TIMEOUT_MS, 'ms');
|
||||
} else {
|
||||
console.error('[CLI Stream] Sync failed:', e);
|
||||
}
|
||||
} finally {
|
||||
syncPromise = null; // Clear the promise to allow future syncs
|
||||
}
|
||||
})();
|
||||
|
||||
console.log(`[CLI Stream] Synced ${executions.length} active execution(s)`);
|
||||
} catch (e) {
|
||||
console.error('[CLI Stream] Sync failed:', e);
|
||||
return syncPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced sync function - prevents rapid successive sync calls
|
||||
* Use this when multiple sync triggers may happen in quick succession
|
||||
*/
|
||||
function syncActiveExecutionsDebounced() {
|
||||
if (syncTimeoutId) {
|
||||
clearTimeout(syncTimeoutId);
|
||||
}
|
||||
syncTimeoutId = setTimeout(function() {
|
||||
syncTimeoutId = null;
|
||||
syncActiveExecutions();
|
||||
}, SYNC_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
// ===== Initialization =====
|
||||
@@ -502,19 +598,24 @@ function renderStreamTabs() {
|
||||
tabsContainer.innerHTML = execIds.map(id => {
|
||||
const exec = cliStreamExecutions[id];
|
||||
const isActive = id === activeStreamTab;
|
||||
const canClose = exec.status !== 'running';
|
||||
|
||||
const isRecovered = exec.recovered === true;
|
||||
|
||||
// Recovery badge HTML
|
||||
const recoveryBadge = isRecovered
|
||||
? `<span class="cli-stream-recovery-badge" title="Session recovered after page refresh">Recovered</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="cli-stream-tab ${isActive ? 'active' : ''}"
|
||||
onclick="switchStreamTab('${id}')"
|
||||
<div class="cli-stream-tab ${isActive ? 'active' : ''} ${isRecovered ? 'recovered' : ''}"
|
||||
onclick="switchStreamTab('${id}')"
|
||||
data-execution-id="${id}">
|
||||
<span class="cli-stream-tab-status ${exec.status}"></span>
|
||||
<span class="cli-stream-tab-tool">${escapeHtml(exec.tool)}</span>
|
||||
<span class="cli-stream-tab-mode">${exec.mode}</span>
|
||||
<button class="cli-stream-tab-close ${canClose ? '' : 'disabled'}"
|
||||
${recoveryBadge}
|
||||
<button class="cli-stream-tab-close"
|
||||
onclick="event.stopPropagation(); closeStream('${id}')"
|
||||
title="${canClose ? _streamT('cliStream.close') : _streamT('cliStream.cannotCloseRunning')}"
|
||||
${canClose ? '' : 'disabled'}>×</button>
|
||||
title="${_streamT('cliStream.close')}">×</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
@@ -589,29 +690,35 @@ function renderStreamContent(executionId) {
|
||||
function renderStreamStatus(executionId) {
|
||||
const statusContainer = document.getElementById('cliStreamStatus');
|
||||
if (!statusContainer) return;
|
||||
|
||||
|
||||
const exec = executionId ? cliStreamExecutions[executionId] : null;
|
||||
|
||||
|
||||
if (!exec) {
|
||||
statusContainer.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = exec.endTime
|
||||
|
||||
const duration = exec.endTime
|
||||
? formatDuration(exec.endTime - exec.startTime)
|
||||
: formatDuration(Date.now() - exec.startTime);
|
||||
|
||||
const statusLabel = exec.status === 'running'
|
||||
|
||||
const statusLabel = exec.status === 'running'
|
||||
? _streamT('cliStream.running')
|
||||
: exec.status === 'completed'
|
||||
? _streamT('cliStream.completed')
|
||||
: _streamT('cliStream.error');
|
||||
|
||||
|
||||
// Recovery badge for status bar
|
||||
const recoveryBadge = exec.recovered
|
||||
? `<span class="cli-status-recovery-badge">Recovered</span>`
|
||||
: '';
|
||||
|
||||
statusContainer.innerHTML = `
|
||||
<div class="cli-stream-status-info">
|
||||
<div class="cli-stream-status-item">
|
||||
<span class="cli-stream-tab-status ${exec.status}"></span>
|
||||
<span>${statusLabel}</span>
|
||||
${recoveryBadge}
|
||||
</div>
|
||||
<div class="cli-stream-status-item">
|
||||
<i data-lucide="clock"></i>
|
||||
@@ -623,15 +730,15 @@ function renderStreamStatus(executionId) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="cli-stream-status-actions">
|
||||
<button class="cli-stream-toggle-btn ${autoScrollEnabled ? 'active' : ''}"
|
||||
onclick="toggleAutoScroll()"
|
||||
<button class="cli-stream-toggle-btn ${autoScrollEnabled ? 'active' : ''}"
|
||||
onclick="toggleAutoScroll()"
|
||||
title="${_streamT('cliStream.autoScroll')}">
|
||||
<i data-lucide="arrow-down-to-line"></i>
|
||||
<span data-i18n="cliStream.autoScroll">${_streamT('cliStream.autoScroll')}</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
|
||||
// Update duration periodically for running executions
|
||||
@@ -656,52 +763,85 @@ function switchStreamTab(executionId) {
|
||||
function updateStreamBadge() {
|
||||
const badge = document.getElementById('cliStreamBadge');
|
||||
if (!badge) return;
|
||||
|
||||
|
||||
const runningCount = Object.values(cliStreamExecutions).filter(e => e.status === 'running').length;
|
||||
|
||||
const totalCount = Object.keys(cliStreamExecutions).length;
|
||||
|
||||
if (runningCount > 0) {
|
||||
badge.textContent = runningCount;
|
||||
badge.classList.add('has-running');
|
||||
} else if (totalCount > 0) {
|
||||
// Show badge for completed executions too (with a different style)
|
||||
badge.textContent = totalCount;
|
||||
badge.classList.remove('has-running');
|
||||
badge.classList.add('has-completed');
|
||||
} else {
|
||||
badge.textContent = '';
|
||||
badge.classList.remove('has-running');
|
||||
badge.classList.remove('has-running', 'has-completed');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== User Actions =====
|
||||
function closeStream(executionId) {
|
||||
const exec = cliStreamExecutions[executionId];
|
||||
if (!exec || exec.status === 'running') return;
|
||||
|
||||
if (!exec) return;
|
||||
|
||||
// Note: We now allow closing running tasks - this just removes from view,
|
||||
// the actual CLI process continues on the server
|
||||
delete cliStreamExecutions[executionId];
|
||||
|
||||
|
||||
// Switch to another tab if this was active
|
||||
if (activeStreamTab === executionId) {
|
||||
const remaining = Object.keys(cliStreamExecutions);
|
||||
activeStreamTab = remaining.length > 0 ? remaining[0] : null;
|
||||
}
|
||||
|
||||
|
||||
renderStreamTabs();
|
||||
renderStreamContent(activeStreamTab);
|
||||
updateStreamBadge();
|
||||
|
||||
// If no executions left, close the viewer
|
||||
if (Object.keys(cliStreamExecutions).length === 0) {
|
||||
toggleCliStreamViewer();
|
||||
}
|
||||
}
|
||||
|
||||
function clearCompletedStreams() {
|
||||
const toRemove = Object.keys(cliStreamExecutions).filter(
|
||||
id => cliStreamExecutions[id].status !== 'running'
|
||||
);
|
||||
|
||||
|
||||
toRemove.forEach(id => delete cliStreamExecutions[id]);
|
||||
|
||||
|
||||
// Update active tab if needed
|
||||
if (activeStreamTab && !cliStreamExecutions[activeStreamTab]) {
|
||||
const remaining = Object.keys(cliStreamExecutions);
|
||||
activeStreamTab = remaining.length > 0 ? remaining[0] : null;
|
||||
}
|
||||
|
||||
|
||||
renderStreamTabs();
|
||||
renderStreamContent(activeStreamTab);
|
||||
updateStreamBadge();
|
||||
|
||||
// If no executions left, close the viewer
|
||||
if (Object.keys(cliStreamExecutions).length === 0) {
|
||||
toggleCliStreamViewer();
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllStreams() {
|
||||
// Clear all executions (both running and completed)
|
||||
const allIds = Object.keys(cliStreamExecutions);
|
||||
|
||||
allIds.forEach(id => delete cliStreamExecutions[id]);
|
||||
activeStreamTab = null;
|
||||
|
||||
renderStreamTabs();
|
||||
renderStreamContent(null);
|
||||
updateStreamBadge();
|
||||
|
||||
// Close the viewer since there's nothing to show
|
||||
toggleCliStreamViewer();
|
||||
}
|
||||
|
||||
function toggleAutoScroll() {
|
||||
@@ -839,6 +979,7 @@ window.handleCliStreamError = handleCliStreamError;
|
||||
window.switchStreamTab = switchStreamTab;
|
||||
window.closeStream = closeStream;
|
||||
window.clearCompletedStreams = clearCompletedStreams;
|
||||
window.clearAllStreams = clearAllStreams;
|
||||
window.toggleAutoScroll = toggleAutoScroll;
|
||||
window.handleSearchInput = handleSearchInput;
|
||||
window.clearSearch = clearSearch;
|
||||
|
||||
@@ -140,6 +140,22 @@ function initWebSocket() {
|
||||
|
||||
wsConnection.onopen = () => {
|
||||
console.log('[WS] Connected');
|
||||
|
||||
// Trigger CLI stream sync on WebSocket reconnection
|
||||
// This allows the viewer to recover after page refresh
|
||||
if (typeof syncActiveExecutions === 'function') {
|
||||
syncActiveExecutions().then(function() {
|
||||
console.log('[WS] CLI executions synced after connection');
|
||||
}).catch(function(err) {
|
||||
console.warn('[WS] Failed to sync CLI executions:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Emit custom event for other components to handle reconnection
|
||||
const reconnectEvent = new CustomEvent('websocket-reconnected', {
|
||||
detail: { timestamp: Date.now() }
|
||||
});
|
||||
window.dispatchEvent(reconnectEvent);
|
||||
};
|
||||
|
||||
wsConnection.onmessage = (event) => {
|
||||
|
||||
@@ -36,6 +36,7 @@ const i18n = {
|
||||
'common.disabled': 'Disabled',
|
||||
'common.yes': 'Yes',
|
||||
'common.no': 'No',
|
||||
'common.na': 'N/A',
|
||||
|
||||
// Header
|
||||
'header.project': 'Project:',
|
||||
@@ -2406,6 +2407,154 @@ const i18n = {
|
||||
'common.copyId': 'Copy ID',
|
||||
'common.copied': 'Copied!',
|
||||
'common.copyError': 'Failed to copy',
|
||||
|
||||
// Loop Monitor
|
||||
'loop.title': 'Loop Monitor',
|
||||
'loop.loops': 'Loops',
|
||||
'loop.all': 'All',
|
||||
'loop.running': 'Running',
|
||||
'loop.paused': 'Paused',
|
||||
'loop.completed': 'Completed',
|
||||
'loop.failed': 'Failed',
|
||||
'loop.tasks': 'Tasks',
|
||||
'loop.newLoop': 'New Loop',
|
||||
'loop.loading': 'Loading loops...',
|
||||
'loop.noLoops': 'No loops found',
|
||||
'loop.noLoopsHint': 'Create a loop task to get started',
|
||||
'loop.selectLoop': 'Select a loop to view details',
|
||||
'loop.selectLoopHint': 'Click on a loop from the list to see its details',
|
||||
'loop.loopNotFound': 'Loop not found',
|
||||
'loop.selectAnotherLoop': 'Select another loop from the list',
|
||||
'loop.task': 'Task',
|
||||
'loop.steps': 'steps',
|
||||
'loop.taskInfo': 'Task Info',
|
||||
'loop.edit': 'Edit',
|
||||
'loop.taskId': 'Task ID',
|
||||
'loop.step': 'Step',
|
||||
'loop.updated': 'Updated',
|
||||
'loop.created': 'Created',
|
||||
'loop.progress': 'Progress',
|
||||
'loop.iteration': 'Iteration',
|
||||
'loop.currentStep': 'Current Step',
|
||||
'loop.cliSequence': 'CLI Sequence',
|
||||
'loop.stateVariables': 'State Variables',
|
||||
'loop.executionHistory': 'Execution History',
|
||||
'loop.failureReason': 'Failure Reason',
|
||||
'loop.pause': 'Pause',
|
||||
'loop.resume': 'Resume',
|
||||
'loop.stop': 'Stop',
|
||||
'loop.confirmStop': 'Stop loop {loopId}?\n\nIteration: {currentIteration}/{maxIterations}\nThis action cannot be undone.',
|
||||
'loop.loopPaused': 'Loop paused',
|
||||
'loop.loopResumed': 'Loop resumed',
|
||||
'loop.loopStopped': 'Loop stopped',
|
||||
'loop.failedToPause': 'Failed to pause',
|
||||
'loop.failedToResume': 'Failed to resume',
|
||||
'loop.failedToStop': 'Failed to stop',
|
||||
'loop.failedToLoad': 'Failed to load loops',
|
||||
'loop.justNow': 'just now',
|
||||
'loop.minutesAgo': '{m}m ago',
|
||||
'loop.hoursAgo': '{h}h ago',
|
||||
'loop.daysAgo': '{d}d ago',
|
||||
'loop.tasksCount': '{count} task(s) with loop enabled',
|
||||
'loop.noLoopTasks': 'No loop-enabled tasks found',
|
||||
'loop.createLoopTask': 'Create Loop Task',
|
||||
'loop.backToLoops': 'Back to Loops',
|
||||
'loop.startLoop': 'Start Loop',
|
||||
'loop.loopStarted': 'Loop started',
|
||||
'loop.failedToStart': 'Failed to start loop',
|
||||
'loop.createTaskFailed': 'Failed to create task',
|
||||
'loop.createLoopModal': 'Create Loop Task',
|
||||
'loop.basicInfo': 'Basic Information',
|
||||
'loop.importFromIssue': 'Import from Issue',
|
||||
'loop.selectIssue': 'Select an Issue',
|
||||
'loop.noIssuesFound': 'No issues found',
|
||||
'loop.fetchIssuesFailed': 'Failed to fetch issues',
|
||||
'loop.fetchIssueFailed': 'Failed to fetch issue',
|
||||
'loop.issueImported': 'Issue imported',
|
||||
'loop.taskTitle': 'Task Title',
|
||||
'loop.taskTitlePlaceholder': 'e.g., Auto Test Fix Loop',
|
||||
'loop.description': 'Description',
|
||||
'loop.descriptionPlaceholder': 'Describe what this loop does...',
|
||||
'loop.loopConfig': 'Loop Configuration',
|
||||
'loop.maxIterations': 'Max Iterations',
|
||||
'loop.errorPolicy': 'Error Policy',
|
||||
'loop.pauseOnError': 'Pause on error',
|
||||
'loop.retryAutomatically': 'Retry automatically',
|
||||
'loop.failImmediately': 'Fail immediately',
|
||||
'loop.maxRetries': 'Max Retries (for retry policy)',
|
||||
'loop.successCondition': 'Success Condition (JavaScript expression)',
|
||||
'loop.successConditionPlaceholder': 'e.g., state_variables.test_stdout.includes(\'passed\')',
|
||||
'loop.availableVars': 'Available: state_variables, current_iteration',
|
||||
'loop.cliSequence': 'CLI Sequence',
|
||||
'loop.addStep': 'Add Step',
|
||||
'loop.stepNumber': 'Step {number}',
|
||||
'loop.stepLabel': 'Step',
|
||||
'loop.removeStep': 'Remove step',
|
||||
'loop.stepId': 'Step ID',
|
||||
'loop.stepIdPlaceholder': 'e.g., run_tests',
|
||||
'loop.tool': 'Tool',
|
||||
'loop.mode': 'Mode',
|
||||
'loop.command': 'Command',
|
||||
'loop.commandPlaceholder': 'e.g., npm test',
|
||||
'loop.promptTemplate': 'Prompt Template (supports [variable_name] substitution)',
|
||||
'loop.promptPlaceholder': 'Enter prompt template...',
|
||||
'loop.onError': 'On Error',
|
||||
'loop.continue': 'Continue',
|
||||
'loop.pause': 'Pause',
|
||||
'loop.failFast': 'Fail Fast',
|
||||
'loop.cancel': 'Cancel',
|
||||
'loop.createAndStart': 'Create Loop',
|
||||
'loop.created': 'Created',
|
||||
'loop.createFailed': 'Create Loop Failed',
|
||||
'loop.taskCreated': 'Task created',
|
||||
'loop.taskCreatedFailedStart': 'Task created but failed to start loop',
|
||||
// V2 Simplified Loop
|
||||
'loop.create': 'Create',
|
||||
'loop.loopCreated': 'Loop created successfully',
|
||||
'loop.titleRequired': 'Title is required',
|
||||
'loop.invalidMaxIterations': 'Max iterations must be between 1 and 100',
|
||||
'loop.loopInfo': 'Loop Info',
|
||||
'loop.v2LoopInfo': 'This is a simplified loop. Tasks are managed independently in the detail view.',
|
||||
'loop.manageTasks': 'Manage Tasks',
|
||||
'loop.taskManagement': 'Task Management',
|
||||
'loop.taskManagementPlaceholder': 'Task management will be available in the next update. Use the v1 loops for full task configuration.',
|
||||
'loop.noTasksYet': 'No tasks configured yet',
|
||||
'loop.back': 'Back',
|
||||
'loop.loopNotFound': 'Loop not found',
|
||||
'loop.selectAnotherLoop': 'Please select another loop from the list',
|
||||
'loop.start': 'Start',
|
||||
'loop.loopStarted': 'Loop started',
|
||||
'loop.failedToStart': 'Failed to start loop',
|
||||
// Task List Management
|
||||
'loop.taskList': 'Task List',
|
||||
'loop.addTask': 'Add Task',
|
||||
'loop.taskDescription': 'Task Description',
|
||||
'loop.taskDescriptionPlaceholder': 'Describe what this task should do...',
|
||||
'loop.modeAnalysis': 'Analysis',
|
||||
'loop.modeWrite': 'Write',
|
||||
'loop.modeReview': 'Review',
|
||||
'loop.save': 'Save',
|
||||
'loop.taskAdded': 'Task added successfully',
|
||||
'loop.addTaskFailed': 'Failed to add task',
|
||||
'loop.editTask': 'Edit Task',
|
||||
'loop.taskUpdated': 'Task updated successfully',
|
||||
'loop.updateTaskFailed': 'Failed to update task',
|
||||
'loop.confirmDeleteTask': 'Are you sure you want to delete this task? This action cannot be undone.',
|
||||
'loop.taskDeleted': 'Task deleted successfully',
|
||||
'loop.deleteTaskFailed': 'Failed to delete task',
|
||||
'loop.deleteTaskError': 'Error deleting task',
|
||||
'loop.loadTasksFailed': 'Failed to load tasks',
|
||||
'loop.loadTasksError': 'Error loading tasks',
|
||||
'loop.tasksReordered': 'Tasks reordered',
|
||||
'loop.saveOrderFailed': 'Failed to save order',
|
||||
'loop.noTasksHint': 'Add your first task to get started',
|
||||
'loop.noDescription': 'No description',
|
||||
'loop.descriptionRequired': 'Description is required',
|
||||
'loop.loadTaskFailed': 'Failed to load task',
|
||||
'loop.loadTaskError': 'Error loading task',
|
||||
'loop.taskTitleHint': 'Enter a descriptive title for your loop',
|
||||
'loop.descriptionHint': 'Optional context about what this loop does',
|
||||
'loop.maxIterationsHint': 'Maximum number of iterations to run (1-100)',
|
||||
},
|
||||
|
||||
zh: {
|
||||
@@ -2436,6 +2585,7 @@ const i18n = {
|
||||
'common.disabled': '已禁用',
|
||||
'common.yes': '是',
|
||||
'common.no': '否',
|
||||
'common.na': '无',
|
||||
|
||||
// Header
|
||||
'header.project': '项目:',
|
||||
@@ -4818,6 +4968,153 @@ const i18n = {
|
||||
'common.copyId': '复制 ID',
|
||||
'common.copied': '已复制!',
|
||||
'common.copyError': '复制失败',
|
||||
|
||||
// Loop Monitor - 循环监控
|
||||
'loop.title': '循环监控',
|
||||
'loop.loops': '循环',
|
||||
'loop.all': '全部',
|
||||
'loop.running': '运行中',
|
||||
'loop.paused': '已暂停',
|
||||
'loop.completed': '已完成',
|
||||
'loop.failed': '失败',
|
||||
'loop.tasks': '任务',
|
||||
'loop.newLoop': '新建循环',
|
||||
'loop.loading': '加载循环中...',
|
||||
'loop.noLoops': '未找到循环',
|
||||
'loop.noLoopsHint': '创建一个循环任务开始使用',
|
||||
'loop.selectLoop': '选择一个循环查看详情',
|
||||
'loop.selectLoopHint': '点击列表中的循环查看其详细信息',
|
||||
'loop.loopNotFound': '循环未找到',
|
||||
'loop.selectAnotherLoop': '从列表中选择另一个循环',
|
||||
'loop.task': '任务',
|
||||
'loop.steps': '个步骤',
|
||||
'loop.taskInfo': '任务信息',
|
||||
'loop.edit': '编辑',
|
||||
'loop.taskId': '任务 ID',
|
||||
'loop.step': '步骤',
|
||||
'loop.updated': '更新时间',
|
||||
'loop.created': '创建时间',
|
||||
'loop.progress': '进度',
|
||||
'loop.iteration': '迭代',
|
||||
'loop.currentStep': '当前步骤',
|
||||
'loop.cliSequence': 'CLI 序列',
|
||||
'loop.stateVariables': '状态变量',
|
||||
'loop.executionHistory': '执行历史',
|
||||
'loop.failureReason': '失败原因',
|
||||
'loop.pause': '暂停',
|
||||
'loop.resume': '恢复',
|
||||
'loop.stop': '停止',
|
||||
'loop.confirmStop': '确定停止循环 {loopId}?\n\n迭代:{currentIteration}/{maxIterations}\n此操作无法撤销。',
|
||||
'loop.loopPaused': '循环已暂停',
|
||||
'loop.loopResumed': '循环已恢复',
|
||||
'loop.loopStopped': '循环已停止',
|
||||
'loop.failedToPause': '暂停失败',
|
||||
'loop.failedToResume': '恢复失败',
|
||||
'loop.failedToStop': '停止失败',
|
||||
'loop.failedToLoad': '加载循环失败',
|
||||
'loop.justNow': '刚刚',
|
||||
'loop.minutesAgo': '{m} 分钟前',
|
||||
'loop.hoursAgo': '{h} 小时前',
|
||||
'loop.daysAgo': '{d} 天前',
|
||||
'loop.tasksCount': '{count} 个启用循环的任务',
|
||||
'loop.noLoopTasks': '未找到启用循环的任务',
|
||||
'loop.createLoopTask': '创建循环任务',
|
||||
'loop.backToLoops': '返回循环列表',
|
||||
'loop.startLoop': '启动循环',
|
||||
'loop.loopStarted': '循环已启动',
|
||||
'loop.failedToStart': '启动循环失败',
|
||||
'loop.createTaskFailed': '创建任务失败',
|
||||
'loop.createLoopModal': '创建循环任务',
|
||||
'loop.basicInfo': '基本信息',
|
||||
'loop.importFromIssue': '从问题导入',
|
||||
'loop.selectIssue': '选择问题',
|
||||
'loop.noIssuesFound': '未找到问题',
|
||||
'loop.fetchIssuesFailed': '获取问题列表失败',
|
||||
'loop.fetchIssueFailed': '获取问题详情失败',
|
||||
'loop.issueImported': '已导入问题',
|
||||
'loop.taskTitle': '任务标题',
|
||||
'loop.taskTitlePlaceholder': '例如:自动测试修复循环',
|
||||
'loop.description': '描述',
|
||||
'loop.descriptionPlaceholder': '描述此循环的功能...',
|
||||
'loop.loopConfig': '循环配置',
|
||||
'loop.maxIterations': '最大迭代次数',
|
||||
'loop.errorPolicy': '错误策略',
|
||||
'loop.pauseOnError': '暂停',
|
||||
'loop.retryAutomatically': '自动重试',
|
||||
'loop.failImmediately': '立即失败',
|
||||
'loop.maxRetries': '最大重试次数(重试策略)',
|
||||
'loop.successCondition': '成功条件(JavaScript 表达式)',
|
||||
'loop.successConditionPlaceholder': '例如:state_variables.test_stdout.includes(\'passed\')',
|
||||
'loop.availableVars': '可用变量:state_variables、current_iteration',
|
||||
'loop.cliSequence': 'CLI 序列',
|
||||
'loop.addStep': '添加步骤',
|
||||
'loop.stepNumber': '步骤 {number}',
|
||||
'loop.stepLabel': '步骤',
|
||||
'loop.removeStep': '移除步骤',
|
||||
'loop.stepId': '步骤 ID',
|
||||
'loop.stepIdPlaceholder': '例如:run_tests',
|
||||
'loop.tool': '工具',
|
||||
'loop.mode': '模式',
|
||||
'loop.command': '命令',
|
||||
'loop.commandPlaceholder': '例如:npm test',
|
||||
'loop.promptTemplate': '提示模板(支持 [variable_name] 变量替换)',
|
||||
'loop.promptPlaceholder': '输入提示模板...',
|
||||
'loop.onError': '错误处理',
|
||||
'loop.continue': '继续',
|
||||
'loop.pause': '暂停',
|
||||
'loop.failFast': '立即失败',
|
||||
'loop.cancel': '取消',
|
||||
'loop.createAndStart': '创建循环',
|
||||
'loop.created': '已创建',
|
||||
'loop.createFailed': '创建循环失败',
|
||||
'loop.taskCreatedFailedStart': '任务已创建,但启动循环失败',
|
||||
// V2 Simplified Loop
|
||||
'loop.create': '创建',
|
||||
'loop.loopCreated': '循环创建成功',
|
||||
'loop.titleRequired': '标题不能为空',
|
||||
'loop.invalidMaxIterations': '最大迭代次数必须在 1 到 100 之间',
|
||||
'loop.loopInfo': '循环信息',
|
||||
'loop.v2LoopInfo': '这是一个简化版循环。任务在详情视图中独立管理。',
|
||||
'loop.manageTasks': '管理任务',
|
||||
'loop.taskManagement': '任务管理',
|
||||
'loop.taskManagementPlaceholder': '任务管理将在后续更新中提供。请使用 v1 循环进行完整任务配置。',
|
||||
'loop.noTasksYet': '尚未配置任务',
|
||||
'loop.back': '返回',
|
||||
'loop.loopNotFound': '循环未找到',
|
||||
'loop.selectAnotherLoop': '请从列表中选择其他循环',
|
||||
'loop.start': '启动',
|
||||
'loop.loopStarted': '循环已启动',
|
||||
'loop.failedToStart': '启动循环失败',
|
||||
// Task List Management
|
||||
'loop.taskList': '任务列表',
|
||||
'loop.addTask': '添加任务',
|
||||
'loop.taskDescription': '任务描述',
|
||||
'loop.taskDescriptionPlaceholder': '描述此任务应该做什么...',
|
||||
'loop.modeAnalysis': '分析',
|
||||
'loop.modeWrite': '编写',
|
||||
'loop.modeReview': '审查',
|
||||
'loop.save': '保存',
|
||||
'loop.taskAdded': '任务添加成功',
|
||||
'loop.addTaskFailed': '添加任务失败',
|
||||
'loop.editTask': '编辑任务',
|
||||
'loop.taskUpdated': '任务更新成功',
|
||||
'loop.updateTaskFailed': '更新任务失败',
|
||||
'loop.confirmDeleteTask': '确定要删除此任务吗?此操作无法撤销。',
|
||||
'loop.taskDeleted': '任务删除成功',
|
||||
'loop.deleteTaskFailed': '删除任务失败',
|
||||
'loop.deleteTaskError': '删除任务时出错',
|
||||
'loop.loadTasksFailed': '加载任务失败',
|
||||
'loop.loadTasksError': '加载任务时出错',
|
||||
'loop.tasksReordered': '任务已重新排序',
|
||||
'loop.saveOrderFailed': '保存排序失败',
|
||||
'loop.noTasksHint': '添加您的第一个任务开始使用',
|
||||
'loop.noDescription': '无描述',
|
||||
'loop.descriptionRequired': '描述不能为空',
|
||||
'loop.loadTaskFailed': '加载任务失败',
|
||||
'loop.loadTaskError': '加载任务时出错',
|
||||
'loop.taskTitleHint': '为循环输入描述性标题',
|
||||
'loop.descriptionHint': '关于循环功能的可选上下文',
|
||||
'loop.maxIterationsHint': '最大迭代次数 (1-100)',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4872,11 +5169,24 @@ function switchLang(lang) {
|
||||
localStorage.setItem('ccw-lang', lang);
|
||||
applyTranslations();
|
||||
updateLangToggle();
|
||||
|
||||
|
||||
// Re-render current view to update dynamic content
|
||||
if (typeof updateContentTitle === 'function') {
|
||||
updateContentTitle();
|
||||
}
|
||||
|
||||
// Re-render loop monitor if visible
|
||||
if (typeof window.selectedLoopId !== 'undefined' && document.getElementById('loopList')) {
|
||||
if (typeof updateLoopStatusLabels === 'function') {
|
||||
updateLoopStatusLabels();
|
||||
}
|
||||
if (typeof renderLoopList === 'function') {
|
||||
renderLoopList();
|
||||
}
|
||||
if (window.selectedLoopId && typeof renderLoopDetail === 'function') {
|
||||
renderLoopDetail(window.selectedLoopId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -130,8 +130,9 @@ async function initCsrfToken() {
|
||||
/**
|
||||
* Sync active CLI executions from server
|
||||
* Called when view is opened to restore running execution state
|
||||
* Note: Renamed from syncActiveExecutions to avoid conflict with cli-stream-viewer.js
|
||||
*/
|
||||
async function syncActiveExecutions() {
|
||||
async function syncActiveExecutionsForManager() {
|
||||
try {
|
||||
var response = await fetch('/api/cli/active');
|
||||
if (!response.ok) return;
|
||||
@@ -1202,7 +1203,7 @@ async function renderCliManager() {
|
||||
}
|
||||
|
||||
// 同步活动执行
|
||||
syncActiveExecutions();
|
||||
syncActiveExecutionsForManager();
|
||||
}
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -767,9 +767,11 @@
|
||||
<button class="cli-stream-search-clear" onclick="clearSearch()" title="Clear search">×</button>
|
||||
</div>
|
||||
<div class="cli-stream-actions">
|
||||
<button class="cli-stream-action-btn" onclick="clearCompletedStreams()" data-i18n="cliStream.clearCompleted">
|
||||
<button class="cli-stream-icon-btn" onclick="clearCompletedStreams()" title="Clear completed">
|
||||
<i data-lucide="check-circle"></i>
|
||||
</button>
|
||||
<button class="cli-stream-icon-btn" onclick="clearAllStreams()" title="Clear all">
|
||||
<i data-lucide="trash-2"></i>
|
||||
<span>Clear</span>
|
||||
</button>
|
||||
<button class="cli-stream-close-btn" onclick="toggleCliStreamViewer()" title="Close">×</button>
|
||||
</div>
|
||||
|
||||
@@ -13,8 +13,8 @@ export class LoopStateManager {
|
||||
private baseDir: string;
|
||||
|
||||
constructor(workflowDir: string) {
|
||||
// State files stored in .workflow/active/WFS-{session}/.loop/
|
||||
this.baseDir = join(workflowDir, '.loop');
|
||||
// State files stored in .workflow/.loop/
|
||||
this.baseDir = join(workflowDir, '.workflow', '.loop');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
380
ccw/src/tools/loop-task-manager.ts
Normal file
380
ccw/src/tools/loop-task-manager.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Loop Task Manager
|
||||
* CCW Loop System - JSONL task persistence layer for v2 loops
|
||||
* Reference: .workflow/.scratchpad/loop-system-complete-design-20260121.md section 4.2
|
||||
*
|
||||
* Storage format: .workflow/.loop/{loopId}/tasks.jsonl
|
||||
* JSONL format: one JSON object per line for efficient append-only operations
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir, copyFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
/**
|
||||
* Loop Task - simplified task definition for v2 loops
|
||||
*/
|
||||
export interface LoopTask {
|
||||
/** Unique task identifier */
|
||||
task_id: string;
|
||||
|
||||
/** Task description (what to do) */
|
||||
description: string;
|
||||
|
||||
/** CLI tool to use */
|
||||
tool: 'bash' | 'gemini' | 'codex' | 'qwen' | 'claude';
|
||||
|
||||
/** Execution mode */
|
||||
mode: 'analysis' | 'write' | 'review';
|
||||
|
||||
/** Prompt template with variable replacement */
|
||||
prompt_template: string;
|
||||
|
||||
/** Display order (for drag-drop reordering) */
|
||||
order: number;
|
||||
|
||||
/** Creation timestamp */
|
||||
created_at: string;
|
||||
|
||||
/** Last update timestamp */
|
||||
updated_at: string;
|
||||
|
||||
/** Optional: custom bash command */
|
||||
command?: string;
|
||||
|
||||
/** Optional: step failure behavior */
|
||||
on_error?: 'continue' | 'pause' | 'fail_fast';
|
||||
}
|
||||
|
||||
/**
|
||||
* Task create request
|
||||
*/
|
||||
export interface TaskCreateRequest {
|
||||
description: string;
|
||||
tool: LoopTask['tool'];
|
||||
mode: LoopTask['mode'];
|
||||
prompt_template: string;
|
||||
command?: string;
|
||||
on_error?: LoopTask['on_error'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Task update request
|
||||
*/
|
||||
export interface TaskUpdateRequest {
|
||||
description?: string;
|
||||
tool?: LoopTask['tool'];
|
||||
mode?: LoopTask['mode'];
|
||||
prompt_template?: string;
|
||||
command?: string;
|
||||
on_error?: LoopTask['on_error'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Task reorder request
|
||||
*/
|
||||
export interface TaskReorderRequest {
|
||||
ordered_task_ids: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Task Storage Manager
|
||||
* Handles JSONL persistence for loop tasks
|
||||
*/
|
||||
export class TaskStorageManager {
|
||||
private baseDir: string;
|
||||
|
||||
constructor(workflowDir: string) {
|
||||
// Task files stored in .workflow/.loop/{loopId}/
|
||||
this.baseDir = join(workflowDir, '.workflow', '.loop');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new task to the loop
|
||||
*/
|
||||
async addTask(loopId: string, request: TaskCreateRequest): Promise<LoopTask> {
|
||||
await this.ensureLoopDir(loopId);
|
||||
|
||||
// Read existing tasks to determine next order
|
||||
const existingTasks = await this.readTasks(loopId);
|
||||
const nextOrder = existingTasks.length > 0
|
||||
? Math.max(...existingTasks.map(t => t.order)) + 1
|
||||
: 0;
|
||||
|
||||
const task: LoopTask = {
|
||||
task_id: this.generateTaskId(),
|
||||
description: request.description,
|
||||
tool: request.tool,
|
||||
mode: request.mode,
|
||||
prompt_template: request.prompt_template,
|
||||
order: nextOrder,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
command: request.command,
|
||||
on_error: request.on_error
|
||||
};
|
||||
|
||||
await this.appendTask(loopId, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tasks for a loop
|
||||
*/
|
||||
async getTasks(loopId: string): Promise<LoopTask[]> {
|
||||
return this.readTasks(loopId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single task by ID
|
||||
*/
|
||||
async getTask(loopId: string, taskId: string): Promise<LoopTask | null> {
|
||||
const tasks = await this.readTasks(loopId);
|
||||
return tasks.find(t => t.task_id === taskId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing task
|
||||
*/
|
||||
async updateTask(loopId: string, taskId: string, updates: TaskUpdateRequest): Promise<LoopTask | null> {
|
||||
const tasks = await this.readTasks(loopId);
|
||||
const taskIndex = tasks.findIndex(t => t.task_id === taskId);
|
||||
|
||||
if (taskIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const task = tasks[taskIndex];
|
||||
const updatedTask: LoopTask = {
|
||||
...task,
|
||||
description: updates.description ?? task.description,
|
||||
tool: updates.tool ?? task.tool,
|
||||
mode: updates.mode ?? task.mode,
|
||||
prompt_template: updates.prompt_template ?? task.prompt_template,
|
||||
command: updates.command ?? task.command,
|
||||
on_error: updates.on_error ?? task.on_error,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
tasks[taskIndex] = updatedTask;
|
||||
await this.writeTasks(loopId, tasks);
|
||||
return updatedTask;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete task and reorder remaining tasks
|
||||
*/
|
||||
async deleteTask(loopId: string, taskId: string): Promise<boolean> {
|
||||
const tasks = await this.readTasks(loopId);
|
||||
const filteredTasks = tasks.filter(t => t.task_id !== taskId);
|
||||
|
||||
if (filteredTasks.length === tasks.length) {
|
||||
return false; // Task not found
|
||||
}
|
||||
|
||||
// Reorder remaining tasks
|
||||
const reorderedTasks = this.reorderTasksByOrder(filteredTasks);
|
||||
|
||||
await this.writeTasks(loopId, reorderedTasks);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder tasks based on provided task ID sequence
|
||||
*/
|
||||
async reorderTasks(loopId: string, request: TaskReorderRequest): Promise<LoopTask[]> {
|
||||
const tasks = await this.readTasks(loopId);
|
||||
const taskMap = new Map(tasks.map(t => [t.task_id, t]));
|
||||
|
||||
// Verify all provided task IDs exist
|
||||
for (const taskId of request.ordered_task_ids) {
|
||||
if (!taskMap.has(taskId)) {
|
||||
throw new Error(`Task not found: ${taskId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder tasks and update order indices
|
||||
const reorderedTasks: LoopTask[] = [];
|
||||
for (let i = 0; i < request.ordered_task_ids.length; i++) {
|
||||
const task = taskMap.get(request.ordered_task_ids[i])!;
|
||||
reorderedTasks.push({
|
||||
...task,
|
||||
order: i,
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Add any tasks not in the reorder list (shouldn't happen normally)
|
||||
for (const task of tasks) {
|
||||
if (!request.ordered_task_ids.includes(task.task_id)) {
|
||||
reorderedTasks.push({
|
||||
...task,
|
||||
order: reorderedTasks.length,
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.writeTasks(loopId, reorderedTasks);
|
||||
return reorderedTasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all tasks for a loop
|
||||
*/
|
||||
async deleteAllTasks(loopId: string): Promise<void> {
|
||||
const tasksPath = this.getTasksPath(loopId);
|
||||
|
||||
if (existsSync(tasksPath)) {
|
||||
const { unlink } = await import('fs/promises');
|
||||
await unlink(tasksPath).catch(() => {});
|
||||
}
|
||||
|
||||
// Also delete backup
|
||||
const backupPath = `${tasksPath}.backup`;
|
||||
if (existsSync(backupPath)) {
|
||||
const { unlink } = await import('fs/promises');
|
||||
await unlink(backupPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read tasks with recovery from backup
|
||||
*/
|
||||
async readTasksWithRecovery(loopId: string): Promise<LoopTask[]> {
|
||||
try {
|
||||
return await this.readTasks(loopId);
|
||||
} catch (error) {
|
||||
console.warn(`Tasks file corrupted, attempting recovery for ${loopId}...`);
|
||||
|
||||
const backupPath = `${this.getTasksPath(loopId)}.backup`;
|
||||
if (existsSync(backupPath)) {
|
||||
const content = await readFile(backupPath, 'utf-8');
|
||||
const tasks = this.parseTasksJsonl(content);
|
||||
// Restore from backup
|
||||
await this.writeTasks(loopId, tasks);
|
||||
return tasks;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tasks file path
|
||||
*/
|
||||
getTasksPath(loopId: string): string {
|
||||
return join(this.baseDir, this.sanitizeLoopId(loopId), 'tasks.jsonl');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read tasks from JSONL file
|
||||
*/
|
||||
private async readTasks(loopId: string): Promise<LoopTask[]> {
|
||||
const filePath = this.getTasksPath(loopId);
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
return this.parseTasksJsonl(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSONL content into tasks array
|
||||
*/
|
||||
private parseTasksJsonl(content: string): LoopTask[] {
|
||||
const tasks: LoopTask[] = [];
|
||||
const lines = content.split('\n').filter(line => line.trim().length > 0);
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const task = JSON.parse(line) as LoopTask;
|
||||
tasks.push(task);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse task line:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write tasks array to JSONL file
|
||||
*/
|
||||
private async writeTasks(loopId: string, tasks: LoopTask[]): Promise<void> {
|
||||
await this.ensureLoopDir(loopId);
|
||||
|
||||
const filePath = this.getTasksPath(loopId);
|
||||
|
||||
// Create backup if file exists
|
||||
if (existsSync(filePath)) {
|
||||
const backupPath = `${filePath}.backup`;
|
||||
await copyFile(filePath, backupPath).catch(() => {});
|
||||
}
|
||||
|
||||
// Write each task as a JSON line
|
||||
const jsonlContent = tasks.map(t => JSON.stringify(t)).join('\n');
|
||||
await writeFile(filePath, jsonlContent, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Append single task to JSONL file
|
||||
*/
|
||||
private async appendTask(loopId: string, task: LoopTask): Promise<void> {
|
||||
await this.ensureLoopDir(loopId);
|
||||
|
||||
const filePath = this.getTasksPath(loopId);
|
||||
|
||||
// Create backup if file exists
|
||||
if (existsSync(filePath)) {
|
||||
const backupPath = `${filePath}.backup`;
|
||||
await copyFile(filePath, backupPath).catch(() => {});
|
||||
}
|
||||
|
||||
// Append task as new line
|
||||
const line = JSON.stringify(task) + '\n';
|
||||
await writeFile(filePath, line, { flag: 'a' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure loop directory exists
|
||||
*/
|
||||
private async ensureLoopDir(loopId: string): Promise<void> {
|
||||
const dirPath = join(this.baseDir, this.sanitizeLoopId(loopId));
|
||||
if (!existsSync(dirPath)) {
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique task ID
|
||||
*/
|
||||
private generateTaskId(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = randomBytes(4).toString('hex');
|
||||
return `task-${timestamp}-${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize loop ID for filesystem usage
|
||||
*/
|
||||
private sanitizeLoopId(loopId: string): string {
|
||||
// Remove any path traversal characters
|
||||
return loopId.replace(/[\/\\]/g, '-').replace(/\.\./g, '').replace(/^\./, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder tasks array by updating order indices sequentially
|
||||
*/
|
||||
private reorderTasksByOrder(tasks: LoopTask[]): LoopTask[] {
|
||||
return tasks
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((task, index) => ({
|
||||
...task,
|
||||
order: index
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,129 @@ export interface ExecutionRecord {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CCW-LOOP SKILL STATE (Unified Architecture)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Skill State - Extension fields managed by ccw-loop skill
|
||||
* Stored in .workflow/.loop/{loopId}.json alongside API fields
|
||||
*/
|
||||
export interface SkillState {
|
||||
/** Current action being executed */
|
||||
current_action: 'init' | 'develop' | 'debug' | 'validate' | 'complete' | null;
|
||||
|
||||
/** Last completed action */
|
||||
last_action: string | null;
|
||||
|
||||
/** List of completed action names */
|
||||
completed_actions: string[];
|
||||
|
||||
/** Execution mode */
|
||||
mode: 'interactive' | 'auto';
|
||||
|
||||
/** Development phase state */
|
||||
develop: {
|
||||
total: number;
|
||||
completed: number;
|
||||
current_task?: string;
|
||||
tasks: DevelopTask[];
|
||||
last_progress_at: string | null;
|
||||
};
|
||||
|
||||
/** Debug phase state */
|
||||
debug: {
|
||||
active_bug?: string;
|
||||
hypotheses_count: number;
|
||||
hypotheses: Hypothesis[];
|
||||
confirmed_hypothesis: string | null;
|
||||
iteration: number;
|
||||
last_analysis_at: string | null;
|
||||
};
|
||||
|
||||
/** Validation phase state */
|
||||
validate: {
|
||||
pass_rate: number;
|
||||
coverage: number;
|
||||
test_results: TestResult[];
|
||||
passed: boolean;
|
||||
failed_tests: string[];
|
||||
last_run_at: string | null;
|
||||
};
|
||||
|
||||
/** Error tracking */
|
||||
errors: Array<{
|
||||
action: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Development task
|
||||
*/
|
||||
export interface DevelopTask {
|
||||
id: string;
|
||||
description: string;
|
||||
tool: 'gemini' | 'qwen' | 'codex' | 'bash';
|
||||
mode: 'analysis' | 'write';
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
files_changed?: string[];
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug hypothesis
|
||||
*/
|
||||
export interface Hypothesis {
|
||||
id: string;
|
||||
description: string;
|
||||
testable_condition: string;
|
||||
logging_point: string;
|
||||
evidence_criteria: {
|
||||
confirm: string;
|
||||
reject: string;
|
||||
};
|
||||
likelihood: number;
|
||||
status: 'pending' | 'confirmed' | 'rejected' | 'inconclusive';
|
||||
evidence?: Record<string, unknown>;
|
||||
verdict_reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test result
|
||||
*/
|
||||
export interface TestResult {
|
||||
test_name: string;
|
||||
suite: string;
|
||||
status: 'passed' | 'failed' | 'skipped';
|
||||
duration_ms: number;
|
||||
error_message?: string;
|
||||
stack_trace?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 Loop Storage Format (simplified, for Dashboard API)
|
||||
* This is the unified state structure used by both API and ccw-loop skill
|
||||
*/
|
||||
export interface V2LoopState {
|
||||
// === API Fields (managed by loop-v2-routes.ts) ===
|
||||
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;
|
||||
|
||||
// === Skill Extension Fields (managed by ccw-loop skill) ===
|
||||
skill_state?: SkillState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task Loop control configuration
|
||||
* Extension to Task JSON schema
|
||||
|
||||
Reference in New Issue
Block a user