feat: Add DevLoop automation and HTML Reports

## DevLoop - Closed-Loop Development System
- Orchestrator for plan → build → test → analyze cycle
- Gemini planning via OpenCode CLI
- Claude implementation via CLI bridge
- Playwright browser testing integration
- Test runner with API, filesystem, and browser tests
- Persistent state in .devloop/ directory
- CLI tool: tools/devloop_cli.py

Usage:
  python tools/devloop_cli.py start 'Create new feature'
  python tools/devloop_cli.py plan 'Fix bug in X'
  python tools/devloop_cli.py test --study support_arm
  python tools/devloop_cli.py browser --level full

## HTML Reports (optimization_engine/reporting/)
- Interactive Plotly-based reports
- Convergence plot, Pareto front, parallel coordinates
- Parameter importance analysis
- Self-contained HTML (offline-capable)
- Tailwind CSS styling

## Playwright E2E Tests
- Home page tests
- Test results in test-results/

## LAC Knowledge Base Updates
- Session insights (failures, workarounds, patterns)
- Optimization memory for arm support study
This commit is contained in:
2026-01-24 21:18:18 -05:00
parent a3f18dc377
commit 3193831340
24 changed files with 6437 additions and 0 deletions

View File

@@ -0,0 +1,342 @@
/**
* DevLoopPanel - Control panel for closed-loop development
*
* Features:
* - Start/stop development cycles
* - Real-time phase monitoring
* - Iteration history view
* - Test result visualization
*/
import { useState, useEffect, useCallback } from 'react';
import {
PlayCircle,
StopCircle,
RefreshCw,
CheckCircle,
XCircle,
AlertCircle,
Clock,
ListChecks,
Zap,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import useWebSocket from 'react-use-websocket';
interface LoopState {
phase: string;
iteration: number;
current_task: string | null;
last_update: string;
}
interface CycleResult {
objective: string;
status: string;
iterations: number;
duration_seconds: number;
}
interface TestResult {
scenario_id: string;
scenario_name: string;
passed: boolean;
duration_ms: number;
error?: string;
}
const PHASE_COLORS: Record<string, string> = {
idle: 'bg-gray-500',
planning: 'bg-blue-500',
implementing: 'bg-purple-500',
testing: 'bg-yellow-500',
analyzing: 'bg-orange-500',
fixing: 'bg-red-500',
verifying: 'bg-green-500',
};
const PHASE_ICONS: Record<string, React.ReactNode> = {
idle: <Clock className="w-4 h-4" />,
planning: <ListChecks className="w-4 h-4" />,
implementing: <Zap className="w-4 h-4" />,
testing: <RefreshCw className="w-4 h-4 animate-spin" />,
analyzing: <AlertCircle className="w-4 h-4" />,
fixing: <Zap className="w-4 h-4" />,
verifying: <CheckCircle className="w-4 h-4" />,
};
export function DevLoopPanel() {
const [state, setState] = useState<LoopState>({
phase: 'idle',
iteration: 0,
current_task: null,
last_update: new Date().toISOString(),
});
const [objective, setObjective] = useState('');
const [history, setHistory] = useState<CycleResult[]>([]);
const [testResults, setTestResults] = useState<TestResult[]>([]);
const [expanded, setExpanded] = useState(true);
const [isStarting, setIsStarting] = useState(false);
// WebSocket connection for real-time updates
const { lastJsonMessage, readyState } = useWebSocket(
'ws://localhost:8000/api/devloop/ws',
{
shouldReconnect: () => true,
reconnectInterval: 3000,
}
);
// Handle WebSocket messages
useEffect(() => {
if (!lastJsonMessage) return;
const msg = lastJsonMessage as any;
switch (msg.type) {
case 'connection_ack':
case 'state_update':
case 'state':
if (msg.state) {
setState(msg.state);
}
break;
case 'cycle_complete':
setHistory(prev => [msg.result, ...prev].slice(0, 10));
setIsStarting(false);
break;
case 'cycle_error':
console.error('DevLoop error:', msg.error);
setIsStarting(false);
break;
case 'test_progress':
if (msg.result) {
setTestResults(prev => [...prev, msg.result]);
}
break;
}
}, [lastJsonMessage]);
// Start a development cycle
const startCycle = useCallback(async () => {
if (!objective.trim()) return;
setIsStarting(true);
setTestResults([]);
try {
const response = await fetch('http://localhost:8000/api/devloop/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
objective: objective.trim(),
max_iterations: 10,
}),
});
if (!response.ok) {
const error = await response.json();
console.error('Failed to start cycle:', error);
setIsStarting(false);
}
} catch (error) {
console.error('Failed to start cycle:', error);
setIsStarting(false);
}
}, [objective]);
// Stop the current cycle
const stopCycle = useCallback(async () => {
try {
await fetch('http://localhost:8000/api/devloop/stop', {
method: 'POST',
});
} catch (error) {
console.error('Failed to stop cycle:', error);
}
}, []);
// Quick start: Create support_arm study
const quickStartSupportArm = useCallback(() => {
setObjective('Create support_arm optimization study with 5 design variables (center_space, arm_thk, arm_angle, end_thk, base_thk), objectives (minimize displacement, minimize mass), and stress constraint (< 30% yield)');
// Auto-start after a brief delay
setTimeout(() => {
startCycle();
}, 500);
}, [startCycle]);
const isActive = state.phase !== 'idle';
const wsConnected = readyState === WebSocket.OPEN;
return (
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 bg-gray-800 cursor-pointer"
onClick={() => setExpanded(!expanded)}
>
<div className="flex items-center gap-2">
{expanded ? (
<ChevronDown className="w-4 h-4 text-gray-400" />
) : (
<ChevronRight className="w-4 h-4 text-gray-400" />
)}
<RefreshCw className="w-5 h-5 text-blue-400" />
<h3 className="font-semibold text-white">DevLoop Control</h3>
</div>
{/* Status indicator */}
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${
wsConnected ? 'bg-green-500' : 'bg-red-500'
}`}
/>
<span className={`px-2 py-1 text-xs rounded ${PHASE_COLORS[state.phase]} text-white`}>
{state.phase.toUpperCase()}
</span>
</div>
</div>
{expanded && (
<div className="p-4 space-y-4">
{/* Objective Input */}
<div>
<label className="block text-sm text-gray-400 mb-1">
Development Objective
</label>
<textarea
value={objective}
onChange={(e) => setObjective(e.target.value)}
placeholder="e.g., Create support_arm optimization study..."
className="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded text-white text-sm resize-none h-20"
disabled={isActive}
/>
</div>
{/* Quick Actions */}
<div className="flex gap-2">
<button
onClick={quickStartSupportArm}
disabled={isActive}
className="px-3 py-1.5 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 text-white text-sm rounded flex items-center gap-1"
>
<Zap className="w-4 h-4" />
Quick: support_arm
</button>
</div>
{/* Control Buttons */}
<div className="flex gap-2">
{!isActive ? (
<button
onClick={startCycle}
disabled={!objective.trim() || isStarting}
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 text-white rounded flex items-center justify-center gap-2"
>
<PlayCircle className="w-5 h-5" />
{isStarting ? 'Starting...' : 'Start Cycle'}
</button>
) : (
<button
onClick={stopCycle}
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded flex items-center justify-center gap-2"
>
<StopCircle className="w-5 h-5" />
Stop Cycle
</button>
)}
</div>
{/* Current Phase Progress */}
{isActive && (
<div className="bg-gray-800 rounded p-3 space-y-2">
<div className="flex items-center gap-2">
{PHASE_ICONS[state.phase]}
<span className="text-sm text-white font-medium">
{state.phase.charAt(0).toUpperCase() + state.phase.slice(1)}
</span>
<span className="text-xs text-gray-400">
Iteration {state.iteration + 1}
</span>
</div>
{state.current_task && (
<p className="text-xs text-gray-400 truncate">
{state.current_task}
</p>
)}
</div>
)}
{/* Test Results */}
{testResults.length > 0 && (
<div className="bg-gray-800 rounded p-3">
<h4 className="text-sm font-medium text-white mb-2">Test Results</h4>
<div className="space-y-1 max-h-32 overflow-y-auto">
{testResults.map((test, i) => (
<div
key={`${test.scenario_id}-${i}`}
className="flex items-center gap-2 text-xs"
>
{test.passed ? (
<CheckCircle className="w-3 h-3 text-green-500" />
) : (
<XCircle className="w-3 h-3 text-red-500" />
)}
<span className="text-gray-300 truncate flex-1">
{test.scenario_name}
</span>
<span className="text-gray-500">
{test.duration_ms.toFixed(0)}ms
</span>
</div>
))}
</div>
</div>
)}
{/* History */}
{history.length > 0 && (
<div className="bg-gray-800 rounded p-3">
<h4 className="text-sm font-medium text-white mb-2">Recent Cycles</h4>
<div className="space-y-2">
{history.slice(0, 3).map((cycle, i) => (
<div
key={i}
className="flex items-center justify-between text-xs"
>
<span className="text-gray-300 truncate flex-1">
{cycle.objective.substring(0, 40)}...
</span>
<span
className={`px-1.5 py-0.5 rounded ${
cycle.status === 'completed'
? 'bg-green-900 text-green-300'
: 'bg-yellow-900 text-yellow-300'
}`}
>
{cycle.status}
</span>
</div>
))}
</div>
</div>
)}
{/* Phase Legend */}
<div className="grid grid-cols-4 gap-2 text-xs">
{Object.entries(PHASE_COLORS).map(([phase, color]) => (
<div key={phase} className="flex items-center gap-1">
<div className={`w-2 h-2 rounded ${color}`} />
<span className="text-gray-400 capitalize">{phase}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
export default DevLoopPanel;

View File

@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}

View File

@@ -0,0 +1,171 @@
import { test, expect } from '@playwright/test';
/**
* Home Page E2E Tests
*
* Tests the study list page at /
* Covers: study loading, topic expansion, navigation
*/
test.describe('Home Page - Study List', () => {
test.beforeEach(async ({ page }) => {
// Navigate to home page
await page.goto('/');
});
test('displays page header', async ({ page }) => {
// Check header is visible
await expect(page.locator('header')).toBeVisible();
// Check for key header elements - Studies heading (exact match to avoid Inbox Studies)
await expect(page.getByRole('heading', { name: 'Studies', exact: true })).toBeVisible({ timeout: 10000 });
});
test('shows aggregate statistics cards', async ({ page }) => {
// Wait for stats to load
await expect(page.getByText('Total Studies')).toBeVisible();
await expect(page.getByText('Running')).toBeVisible();
await expect(page.getByText('Total Trials')).toBeVisible();
await expect(page.getByText('Best Overall')).toBeVisible();
});
test('loads studies table with topic folders', async ({ page }) => {
// Wait for studies section (exact match to avoid Inbox Studies)
await expect(page.getByRole('heading', { name: 'Studies', exact: true })).toBeVisible();
// Wait for loading to complete - either see folders or empty state
// Folders have "trials" text in them
const folderLocator = page.locator('button:has-text("trials")');
const emptyStateLocator = page.getByText('No studies found');
// Wait for either studies loaded or empty state (10s timeout)
await expect(folderLocator.first().or(emptyStateLocator)).toBeVisible({ timeout: 10000 });
});
test('expands topic folder to show studies', async ({ page }) => {
// Wait for folders to load
const folderButton = page.locator('button:has-text("trials")').first();
// Wait for folder to be visible (studies loaded)
await expect(folderButton).toBeVisible({ timeout: 10000 });
// Click to expand
await folderButton.click();
// After expansion, study rows should be visible (they have status badges)
// Status badges contain: running, completed, idle, paused, not_started
const statusBadges = page.locator('span:has-text("running"), span:has-text("completed"), span:has-text("idle"), span:has-text("paused"), span:has-text("not_started")');
await expect(statusBadges.first()).toBeVisible({ timeout: 5000 });
});
test('clicking study shows preview panel', async ({ page }) => {
// Wait for and expand first folder
const folderButton = page.locator('button:has-text("trials")').first();
await expect(folderButton).toBeVisible({ timeout: 10000 });
await folderButton.click();
// Wait for expanded content and click first study row
const studyRow = page.locator('.bg-dark-850\\/50 > div').first();
await expect(studyRow).toBeVisible({ timeout: 5000 });
await studyRow.click();
// Preview panel should show with buttons - use exact match to avoid header nav button
await expect(page.getByRole('button', { name: 'Canvas', exact: true })).toBeVisible({ timeout: 5000 });
await expect(page.getByRole('button', { name: 'Open' })).toBeVisible();
});
test('Open button navigates to dashboard', async ({ page }) => {
// Wait for and expand first folder
const folderButton = page.locator('button:has-text("trials")').first();
await expect(folderButton).toBeVisible({ timeout: 10000 });
await folderButton.click();
// Wait for and click study row
const studyRow = page.locator('.bg-dark-850\\/50 > div').first();
await expect(studyRow).toBeVisible({ timeout: 5000 });
await studyRow.click();
// Wait for and click Open button
const openButton = page.getByRole('button', { name: 'Open' });
await expect(openButton).toBeVisible({ timeout: 5000 });
await openButton.click();
// Should navigate to dashboard
await expect(page).toHaveURL(/\/dashboard/);
});
test('Canvas button navigates to canvas view', async ({ page }) => {
// Wait for and expand first folder
const folderButton = page.locator('button:has-text("trials")').first();
await expect(folderButton).toBeVisible({ timeout: 10000 });
await folderButton.click();
// Wait for and click study row
const studyRow = page.locator('.bg-dark-850\\/50 > div').first();
await expect(studyRow).toBeVisible({ timeout: 5000 });
await studyRow.click();
// Wait for and click Canvas button (exact match to avoid header nav)
const canvasButton = page.getByRole('button', { name: 'Canvas', exact: true });
await expect(canvasButton).toBeVisible({ timeout: 5000 });
await canvasButton.click();
// Should navigate to canvas
await expect(page).toHaveURL(/\/canvas\//);
});
test('refresh button reloads studies', async ({ page }) => {
// Find the main studies section refresh button (the one with visible text "Refresh")
const refreshButton = page.getByText('Refresh');
await expect(refreshButton).toBeVisible({ timeout: 5000 });
// Click refresh
await refreshButton.click();
// Should show loading state or complete quickly
// Just verify no errors occurred (exact match to avoid Inbox Studies)
await expect(page.getByRole('heading', { name: 'Studies', exact: true })).toBeVisible();
});
});
/**
* Inbox Section Tests
*
* Tests the new study intake workflow
*/
test.describe('Home Page - Inbox Section', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('displays inbox section with header', async ({ page }) => {
// Check for Study Inbox heading (section is expanded by default)
const inboxHeading = page.getByRole('heading', { name: 'Study Inbox' });
await expect(inboxHeading).toBeVisible({ timeout: 10000 });
});
test('inbox section shows pending count', async ({ page }) => {
// Section should show pending studies count
const pendingText = page.getByText(/\d+ pending studies/);
await expect(pendingText).toBeVisible({ timeout: 10000 });
});
test('inbox has new study button', async ({ page }) => {
// Section is expanded by default, look for the New Study button
const newStudyButton = page.getByRole('button', { name: /New Study/ });
await expect(newStudyButton).toBeVisible({ timeout: 10000 });
});
test('clicking new study shows create form', async ({ page }) => {
// Click the New Study button
const newStudyButton = page.getByRole('button', { name: /New Study/ });
await expect(newStudyButton).toBeVisible({ timeout: 10000 });
await newStudyButton.click();
// Form should expand with input fields
const studyNameInput = page.getByPlaceholder(/my_study/i).or(page.locator('input[type="text"]').first());
await expect(studyNameInput).toBeVisible({ timeout: 5000 });
});
});