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:
catlog22
2026-01-22 10:13:00 +08:00
parent d9f1d14d5e
commit 60eab98782
37 changed files with 12347 additions and 917 deletions

View File

@@ -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 = [

View File

@@ -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',

File diff suppressed because it is too large Load Diff

View File

@@ -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) => {

View File

@@ -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();

View File

@@ -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;

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -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) => {

View File

@@ -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);
}
}
}
}

View File

@@ -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

View File

@@ -767,9 +767,11 @@
<button class="cli-stream-search-clear" onclick="clearSearch()" title="Clear search">&times;</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">&times;</button>
</div>

View File

@@ -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');
}
/**

View 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
}));
}
}

View File

@@ -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