feat: Add Claude Code terminal integration to dashboard

- Add embedded Claude Code terminal with xterm.js for full CLI experience
- Create WebSocket PTY backend for real-time terminal communication
- Add terminal status endpoint to check CLI availability
- Update dashboard to use Claude Code terminal instead of API chat
- Add optimization control panel with start/stop/validate actions
- Add study context provider for global state management
- Update frontend with new dependencies (xterm.js addons)
- Comprehensive README documentation for all new features

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Antoine
2025-12-04 15:02:13 -05:00
parent 8cbdbcad78
commit 9eed4d81eb
23 changed files with 5060 additions and 339 deletions

View File

@@ -5,12 +5,16 @@ Handles study status, history retrieval, and control operations
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from fastapi.responses import JSONResponse, FileResponse
from pydantic import BaseModel
from pathlib import Path
from typing import List, Dict, Optional
import json
import sys
import sqlite3
import shutil
import subprocess
import psutil
import signal
from datetime import datetime
# Add project root to path
@@ -1024,3 +1028,620 @@ async def get_study_report(study_id: str):
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to read study report: {str(e)}")
# ============================================================================
# Study README and Config Endpoints
# ============================================================================
@router.get("/studies/{study_id}/readme")
async def get_study_readme(study_id: str):
"""
Get the README.md file content for a study (from 1_setup folder)
Args:
study_id: Study identifier
Returns:
JSON with the markdown content
"""
try:
study_dir = STUDIES_DIR / study_id
if not study_dir.exists():
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
# Look for README.md in various locations
readme_paths = [
study_dir / "README.md",
study_dir / "1_setup" / "README.md",
study_dir / "readme.md",
]
readme_content = None
readme_path = None
for path in readme_paths:
if path.exists():
readme_path = path
with open(path, 'r', encoding='utf-8') as f:
readme_content = f.read()
break
if readme_content is None:
# Generate a basic README from config if none exists
config_file = study_dir / "1_setup" / "optimization_config.json"
if not config_file.exists():
config_file = study_dir / "optimization_config.json"
if config_file.exists():
with open(config_file) as f:
config = json.load(f)
readme_content = f"""# {config.get('study_name', study_id)}
{config.get('description', 'No description available.')}
## Design Variables
{chr(10).join([f"- **{dv['name']}**: {dv.get('min', '?')} - {dv.get('max', '?')} {dv.get('units', '')}" for dv in config.get('design_variables', [])])}
## Objectives
{chr(10).join([f"- **{obj['name']}**: {obj.get('description', '')} ({obj.get('direction', 'minimize')})" for obj in config.get('objectives', [])])}
"""
else:
readme_content = f"# {study_id}\n\nNo README or configuration found for this study."
return {
"content": readme_content,
"path": str(readme_path) if readme_path else None,
"study_id": study_id
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to read README: {str(e)}")
@router.get("/studies/{study_id}/config")
async def get_study_config(study_id: str):
"""
Get the full optimization_config.json for a study
Args:
study_id: Study identifier
Returns:
JSON with the complete configuration
"""
try:
study_dir = STUDIES_DIR / study_id
if not study_dir.exists():
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
# Look for config in various locations
config_file = study_dir / "1_setup" / "optimization_config.json"
if not config_file.exists():
config_file = study_dir / "optimization_config.json"
if not config_file.exists():
raise HTTPException(status_code=404, detail=f"Config file not found for study {study_id}")
with open(config_file) as f:
config = json.load(f)
return {
"config": config,
"path": str(config_file),
"study_id": study_id
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to read config: {str(e)}")
# ============================================================================
# Process Control Endpoints
# ============================================================================
# Track running processes by study_id
_running_processes: Dict[str, int] = {}
def _find_optimization_process(study_id: str) -> Optional[psutil.Process]:
"""Find a running optimization process for a given study"""
study_dir = STUDIES_DIR / study_id
for proc in psutil.process_iter(['pid', 'name', 'cmdline', 'cwd']):
try:
cmdline = proc.info.get('cmdline') or []
cmdline_str = ' '.join(cmdline) if cmdline else ''
# Check if this is a Python process running run_optimization.py for this study
if 'python' in cmdline_str.lower() and 'run_optimization' in cmdline_str:
if study_id in cmdline_str or str(study_dir) in cmdline_str:
return proc
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
return None
@router.get("/studies/{study_id}/process")
async def get_process_status(study_id: str):
"""
Get the process status for a study's optimization run
Args:
study_id: Study identifier
Returns:
JSON with process status (is_running, pid, iteration counts)
"""
try:
study_dir = STUDIES_DIR / study_id
if not study_dir.exists():
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
# Check if process is running
proc = _find_optimization_process(study_id)
is_running = proc is not None
pid = proc.pid if proc else None
# Get iteration counts from database
results_dir = get_results_dir(study_dir)
study_db = results_dir / "study.db"
fea_count = 0
nn_count = 0
iteration = None
if study_db.exists():
try:
conn = sqlite3.connect(str(study_db))
cursor = conn.cursor()
# Count FEA trials (from main study or studies with "_fea" suffix)
cursor.execute("""
SELECT COUNT(*) FROM trials t
JOIN studies s ON t.study_id = s.study_id
WHERE t.state = 'COMPLETE'
AND (s.study_name LIKE '%_fea' OR s.study_name NOT LIKE '%_nn%')
""")
fea_count = cursor.fetchone()[0]
# Count NN trials
cursor.execute("""
SELECT COUNT(*) FROM trials t
JOIN studies s ON t.study_id = s.study_id
WHERE t.state = 'COMPLETE'
AND s.study_name LIKE '%_nn%'
""")
nn_count = cursor.fetchone()[0]
# Try to get current iteration from study names
cursor.execute("""
SELECT study_name FROM studies
WHERE study_name LIKE '%_iter%'
ORDER BY study_name DESC LIMIT 1
""")
result = cursor.fetchone()
if result:
import re
match = re.search(r'iter(\d+)', result[0])
if match:
iteration = int(match.group(1))
conn.close()
except Exception as e:
print(f"Warning: Failed to read database for process status: {e}")
return {
"is_running": is_running,
"pid": pid,
"iteration": iteration,
"fea_count": fea_count,
"nn_count": nn_count,
"study_id": study_id
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get process status: {str(e)}")
class StartOptimizationRequest(BaseModel):
freshStart: bool = False
maxIterations: int = 100
feaBatchSize: int = 5
tuneTrials: int = 30
ensembleSize: int = 3
patience: int = 5
@router.post("/studies/{study_id}/start")
async def start_optimization(study_id: str, request: StartOptimizationRequest = None):
"""
Start the optimization process for a study
Args:
study_id: Study identifier
request: Optional start options
Returns:
JSON with process info
"""
try:
study_dir = STUDIES_DIR / study_id
if not study_dir.exists():
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
# Check if already running
existing_proc = _find_optimization_process(study_id)
if existing_proc:
return {
"success": False,
"message": f"Optimization already running (PID: {existing_proc.pid})",
"pid": existing_proc.pid
}
# Find run_optimization.py
run_script = study_dir / "run_optimization.py"
if not run_script.exists():
raise HTTPException(status_code=404, detail=f"run_optimization.py not found for study {study_id}")
# Build command with arguments
python_exe = sys.executable
cmd = [python_exe, str(run_script), "--start"]
if request:
if request.freshStart:
cmd.append("--fresh")
cmd.extend(["--fea-batch", str(request.feaBatchSize)])
cmd.extend(["--tune-trials", str(request.tuneTrials)])
cmd.extend(["--ensemble-size", str(request.ensembleSize)])
cmd.extend(["--patience", str(request.patience)])
# Start process in background
proc = subprocess.Popen(
cmd,
cwd=str(study_dir),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True
)
_running_processes[study_id] = proc.pid
return {
"success": True,
"message": f"Optimization started successfully",
"pid": proc.pid,
"command": ' '.join(cmd)
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to start optimization: {str(e)}")
class StopRequest(BaseModel):
force: bool = True # Default to force kill
@router.post("/studies/{study_id}/stop")
async def stop_optimization(study_id: str, request: StopRequest = None):
"""
Stop the optimization process for a study (hard kill by default)
Args:
study_id: Study identifier
request.force: If True (default), immediately kill. If False, try graceful first.
Returns:
JSON with result
"""
if request is None:
request = StopRequest()
try:
study_dir = STUDIES_DIR / study_id
if not study_dir.exists():
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
# Find running process
proc = _find_optimization_process(study_id)
if not proc:
return {
"success": False,
"message": "No running optimization process found"
}
pid = proc.pid
killed_pids = []
try:
# FIRST: Get all children BEFORE killing parent
children = []
try:
children = proc.children(recursive=True)
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
if request.force:
# Hard kill: immediately kill parent and all children
# Kill children first (bottom-up)
for child in reversed(children):
try:
child.kill() # SIGKILL on Unix, TerminateProcess on Windows
killed_pids.append(child.pid)
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
# Then kill parent
try:
proc.kill()
killed_pids.append(pid)
except psutil.NoSuchProcess:
pass
else:
# Graceful: try SIGTERM first, then force
try:
proc.terminate()
proc.wait(timeout=5)
except psutil.TimeoutExpired:
# Didn't stop gracefully, force kill
for child in reversed(children):
try:
child.kill()
killed_pids.append(child.pid)
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
proc.kill()
killed_pids.append(pid)
except psutil.NoSuchProcess:
pass
# Clean up tracking
if study_id in _running_processes:
del _running_processes[study_id]
return {
"success": True,
"message": f"Optimization killed (PID: {pid}, +{len(children)} children)",
"pid": pid,
"killed_pids": killed_pids
}
except psutil.NoSuchProcess:
if study_id in _running_processes:
del _running_processes[study_id]
return {
"success": True,
"message": "Process already terminated"
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to stop optimization: {str(e)}")
class ValidateRequest(BaseModel):
topN: int = 5
@router.post("/studies/{study_id}/validate")
async def validate_optimization(study_id: str, request: ValidateRequest = None):
"""
Run final FEA validation on top NN predictions
Args:
study_id: Study identifier
request: Validation options (topN)
Returns:
JSON with process info
"""
try:
study_dir = STUDIES_DIR / study_id
if not study_dir.exists():
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
# Check if optimization is still running
existing_proc = _find_optimization_process(study_id)
if existing_proc:
return {
"success": False,
"message": "Cannot validate while optimization is running. Stop optimization first."
}
# Look for final_validation.py script
validation_script = study_dir / "final_validation.py"
if not validation_script.exists():
# Fall back to run_optimization.py with --validate flag if script doesn't exist
run_script = study_dir / "run_optimization.py"
if not run_script.exists():
raise HTTPException(status_code=404, detail="No validation script found")
python_exe = sys.executable
top_n = request.topN if request else 5
cmd = [python_exe, str(run_script), "--validate", "--top", str(top_n)]
else:
python_exe = sys.executable
top_n = request.topN if request else 5
cmd = [python_exe, str(validation_script), "--top", str(top_n)]
# Start validation process
proc = subprocess.Popen(
cmd,
cwd=str(study_dir),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True
)
return {
"success": True,
"message": f"Validation started for top {top_n} NN predictions",
"pid": proc.pid,
"command": ' '.join(cmd)
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to start validation: {str(e)}")
# ============================================================================
# Optuna Dashboard Launch
# ============================================================================
_optuna_processes: Dict[str, subprocess.Popen] = {}
@router.post("/studies/{study_id}/optuna-dashboard")
async def launch_optuna_dashboard(study_id: str):
"""
Launch Optuna dashboard for a specific study
Args:
study_id: Study identifier
Returns:
JSON with dashboard URL and process info
"""
import time
import socket
def is_port_in_use(port: int) -> bool:
"""Check if a port is already in use"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
return s.connect_ex(('localhost', port)) == 0
try:
study_dir = STUDIES_DIR / study_id
if not study_dir.exists():
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
results_dir = get_results_dir(study_dir)
study_db = results_dir / "study.db"
if not study_db.exists():
raise HTTPException(status_code=404, detail=f"No Optuna database found for study {study_id}")
port = 8081
# Check if dashboard is already running on this port
if is_port_in_use(port):
# Check if it's our process
if study_id in _optuna_processes:
proc = _optuna_processes[study_id]
if proc.poll() is None: # Still running
return {
"success": True,
"url": f"http://localhost:{port}",
"pid": proc.pid,
"message": "Optuna dashboard already running"
}
# Port in use but not by us - still return success since dashboard is available
return {
"success": True,
"url": f"http://localhost:{port}",
"pid": None,
"message": "Optuna dashboard already running on port 8081"
}
# Launch optuna-dashboard using Python script
python_exe = sys.executable
# Use absolute path with POSIX format for SQLite URL
abs_db_path = study_db.absolute().as_posix()
storage_url = f"sqlite:///{abs_db_path}"
# Create a small Python script to run optuna-dashboard
launch_script = f'''
from optuna_dashboard import run_server
run_server("{storage_url}", host="0.0.0.0", port={port})
'''
cmd = [python_exe, "-c", launch_script]
# On Windows, use CREATE_NEW_PROCESS_GROUP and DETACHED_PROCESS flags
import platform
if platform.system() == 'Windows':
# Windows-specific: create detached process
DETACHED_PROCESS = 0x00000008
CREATE_NEW_PROCESS_GROUP = 0x00000200
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
)
else:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True
)
_optuna_processes[study_id] = proc
# Wait for dashboard to start (check port repeatedly)
max_wait = 5 # seconds
start_time = time.time()
while time.time() - start_time < max_wait:
if is_port_in_use(port):
return {
"success": True,
"url": f"http://localhost:{port}",
"pid": proc.pid,
"message": "Optuna dashboard launched successfully"
}
# Check if process died
if proc.poll() is not None:
stderr = ""
try:
stderr = proc.stderr.read().decode() if proc.stderr else ""
except:
pass
return {
"success": False,
"message": f"Failed to start Optuna dashboard: {stderr}"
}
time.sleep(0.5)
# Timeout - process might still be starting
if proc.poll() is None:
return {
"success": True,
"url": f"http://localhost:{port}",
"pid": proc.pid,
"message": "Optuna dashboard starting (may take a moment)"
}
else:
stderr = ""
try:
stderr = proc.stderr.read().decode() if proc.stderr else ""
except:
pass
return {
"success": False,
"message": f"Failed to start Optuna dashboard: {stderr}"
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to launch Optuna dashboard: {str(e)}")