docs: Comprehensive documentation update for Dashboard V3 and Canvas

## Documentation Updates
- DASHBOARD.md: Updated to V3.0 with Canvas V3 features, file browser, introspection
- DASHBOARD_IMPLEMENTATION_STATUS.md: Marked Canvas V3 features as COMPLETE
- CANVAS.md: New comprehensive guide for Canvas Builder V3 with all features
- CLAUDE.md: Added dashboard quick reference and Canvas V3 features

## Canvas V3 Features Documented
- File Browser: Browse studies directory for model files
- Model Introspection: Auto-discover expressions, solver type, dependencies
- One-Click Add: Add expressions as design variables instantly
- Claude Bug Fixes: WebSocket reconnection, SQL errors resolved
- Health Check: /api/health endpoint for monitoring

## Backend Services
- NX introspection service with expression discovery
- File browser API with type filtering
- Claude session management improvements
- Context builder enhancements

## Frontend Components
- FileBrowser: Modal for file selection with search
- IntrospectionPanel: View discovered model information
- ExpressionSelector: Dropdown for design variable configuration
- Improved chat hooks with reconnection logic

## Plan Documents
- Added RALPH_LOOP_CANVAS_V2/V3 implementation records
- Added ATOMIZER_DASHBOARD_V2_MASTER_PLAN
- Added investigation and sync documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-16 20:48:58 -05:00
parent 1c7c7aff05
commit ac5e9b4054
23 changed files with 10860 additions and 773 deletions

View File

@@ -2,12 +2,15 @@
Session Manager
Manages persistent Claude Code sessions with MCP integration.
Fixed for Windows compatibility - uses subprocess.Popen with ThreadPoolExecutor.
"""
import asyncio
import json
import os
import subprocess
import uuid
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
@@ -20,6 +23,9 @@ from .context_builder import ContextBuilder
ATOMIZER_ROOT = Path(__file__).parent.parent.parent.parent.parent
MCP_SERVER_PATH = ATOMIZER_ROOT / "mcp-server" / "atomizer-tools"
# Thread pool for subprocess operations (Windows compatible)
_executor = ThreadPoolExecutor(max_workers=4)
@dataclass
class ClaudeSession:
@@ -28,13 +34,12 @@ class ClaudeSession:
session_id: str
mode: Literal["user", "power"]
study_id: Optional[str]
process: Optional[asyncio.subprocess.Process] = None
created_at: datetime = field(default_factory=datetime.now)
last_active: datetime = field(default_factory=datetime.now)
def is_alive(self) -> bool:
"""Check if the subprocess is still running"""
return self.process is not None and self.process.returncode is None
"""Session is always 'alive' - we use stateless CLI calls"""
return True
class SessionManager:
@@ -45,7 +50,7 @@ class SessionManager:
self.store = ConversationStore()
self.context_builder = ContextBuilder()
self._cleanup_task: Optional[asyncio.Task] = None
self._lock: Optional[asyncio.Lock] = None # Created lazily in async context
self._lock: Optional[asyncio.Lock] = None
def _get_lock(self) -> asyncio.Lock:
"""Get or create the async lock (must be called from async context)"""
@@ -55,7 +60,6 @@ class SessionManager:
async def start(self):
"""Start the session manager"""
# Start periodic cleanup of stale sessions
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
async def stop(self):
@@ -67,9 +71,9 @@ class SessionManager:
except asyncio.CancelledError:
pass
# Terminate all sessions
# Clean up temp files
for session in list(self.sessions.values()):
await self._terminate_session(session)
self._cleanup_session_files(session.session_id)
async def create_session(
self,
@@ -80,22 +84,16 @@ class SessionManager:
"""
Create or resume a Claude Code session.
Args:
mode: "user" for safe mode, "power" for full access
study_id: Optional study context
resume_session_id: Optional session ID to resume
Returns:
ClaudeSession object
Note: Sessions are now stateless - we don't spawn persistent processes.
Each message is handled via a one-shot CLI call for Windows compatibility.
"""
async with self._get_lock():
# Resume existing session if requested and alive
# Resume existing session if requested
if resume_session_id and resume_session_id in self.sessions:
session = self.sessions[resume_session_id]
if session.is_alive():
session.last_active = datetime.now()
self.store.touch_session(session.session_id)
return session
session.last_active = datetime.now()
self.store.touch_session(session.session_id)
return session
session_id = resume_session_id or str(uuid.uuid4())[:8]
@@ -112,51 +110,11 @@ class SessionManager:
with open(mcp_config_path, "w") as f:
json.dump(mcp_config, f)
# Build system prompt with context
history = self.store.get_history(session_id) if resume_session_id else []
system_prompt = self.context_builder.build(
mode=mode,
study_id=study_id,
conversation_history=history,
)
# Write system prompt to temp file
prompt_path = ATOMIZER_ROOT / f".claude-prompt-{session_id}.md"
with open(prompt_path, "w") as f:
f.write(system_prompt)
# Build environment
env = os.environ.copy()
env["ATOMIZER_MODE"] = mode
env["ATOMIZER_ROOT"] = str(ATOMIZER_ROOT)
if study_id:
env["ATOMIZER_STUDY"] = study_id
# Start Claude Code subprocess
# Note: claude CLI with appropriate flags for JSON streaming
try:
process = await asyncio.create_subprocess_exec(
"claude",
"--print", # Non-interactive mode
"--output-format", "stream-json",
"--mcp-config", str(mcp_config_path),
"--system-prompt", str(prompt_path),
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(ATOMIZER_ROOT),
env=env,
)
except FileNotFoundError:
# Claude CLI not found - create session without process
# Frontend will get error on first message
process = None
# Create session object (no subprocess - stateless)
session = ClaudeSession(
session_id=session_id,
mode=mode,
study_id=study_id,
process=process,
)
self.sessions[session_id] = session
@@ -166,19 +124,17 @@ class SessionManager:
self,
session_id: str,
message: str,
canvas_state: Optional[Dict] = None,
) -> AsyncGenerator[Dict, None]:
"""
Send a message to a session and stream the response.
Uses one-shot Claude CLI calls (claude --print) since the CLI
doesn't support persistent interactive sessions via stdin/stdout.
Uses synchronous subprocess.Popen via ThreadPoolExecutor for Windows compatibility.
Args:
session_id: Session ID
session_id: The session ID
message: User message
Yields:
Response chunks (text, tool_calls, errors, done)
canvas_state: Optional canvas state (nodes, edges) from UI
"""
session = self.sessions.get(session_id)
@@ -191,23 +147,20 @@ class SessionManager:
# Store user message
self.store.add_message(session_id, "user", message)
# Build context with conversation history
# Build context with conversation history AND canvas state
history = self.store.get_history(session_id, limit=10)
full_prompt = self.context_builder.build(
mode=session.mode,
study_id=session.study_id,
conversation_history=history[:-1], # Exclude current message
conversation_history=history[:-1],
canvas_state=canvas_state, # Pass canvas state for context
)
full_prompt += f"\n\nUser: {message}\n\nRespond helpfully and concisely:"
# Run Claude CLI one-shot
full_response = ""
tool_calls: List[Dict] = []
# Build CLI arguments based on mode
# Build CLI arguments
cli_args = ["claude", "--print"]
# Ensure MCP config exists for atomizer tools
# Ensure MCP config exists
mcp_config_path = ATOMIZER_ROOT / f".claude-mcp-{session_id}.json"
if not mcp_config_path.exists():
mcp_config = self._build_mcp_config(session.mode)
@@ -216,56 +169,61 @@ class SessionManager:
cli_args.extend(["--mcp-config", str(mcp_config_path)])
if session.mode == "user":
# User mode: Allow safe operations including report generation
# Allow Write tool for report files (STUDY_REPORT.md, *.md in study dirs)
cli_args.extend([
"--allowedTools",
"Read Write(**/STUDY_REPORT.md) Write(**/3_results/*.md) Bash(python:*) mcp__atomizer-tools__*"
])
else:
# Power mode: Full access
cli_args.append("--dangerously-skip-permissions")
# Pass prompt via stdin (handles long prompts and special characters)
cli_args.append("-") # Read from stdin
full_response = ""
tool_calls: List[Dict] = []
try:
process = await asyncio.create_subprocess_exec(
*cli_args,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(ATOMIZER_ROOT),
)
loop = asyncio.get_event_loop()
# Send prompt via stdin
process.stdin.write(full_prompt.encode())
await process.stdin.drain()
process.stdin.close()
await process.stdin.wait_closed()
# Run subprocess in thread pool (Windows compatible)
def run_claude():
try:
process = subprocess.Popen(
cli_args,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=str(ATOMIZER_ROOT),
text=True,
encoding='utf-8',
errors='replace',
)
stdout, stderr = process.communicate(input=full_prompt, timeout=300)
return {
"stdout": stdout,
"stderr": stderr,
"returncode": process.returncode,
}
except subprocess.TimeoutExpired:
process.kill()
return {"error": "Response timeout (5 minutes)"}
except FileNotFoundError:
return {"error": "Claude CLI not found in PATH. Install with: npm install -g @anthropic-ai/claude-code"}
except Exception as e:
return {"error": str(e)}
# Stream stdout
buffer = ""
while True:
chunk = await process.stdout.read(100)
if not chunk:
break
result = await loop.run_in_executor(_executor, run_claude)
text = chunk.decode()
full_response += text
yield {"type": "text", "content": text}
if "error" in result:
yield {"type": "error", "message": result["error"]}
else:
full_response = result["stdout"] or ""
await process.wait()
if full_response:
yield {"type": "text", "content": full_response}
if process.returncode != 0:
stderr = await process.stderr.read()
error_msg = stderr.decode() if stderr else "Unknown error"
yield {"type": "error", "message": f"CLI error: {error_msg}"}
if result["returncode"] != 0 and result["stderr"]:
yield {"type": "error", "message": f"CLI error: {result['stderr']}"}
except asyncio.TimeoutError:
yield {"type": "error", "message": "Response timeout"}
except FileNotFoundError:
yield {"type": "error", "message": "Claude CLI not found in PATH"}
except Exception as e:
yield {"type": "error", "message": str(e)}
@@ -285,31 +243,21 @@ class SessionManager:
session_id: str,
new_mode: Literal["user", "power"],
) -> ClaudeSession:
"""
Switch a session's mode (requires restart).
Args:
session_id: Session to switch
new_mode: New mode ("user" or "power")
Returns:
New ClaudeSession with updated mode
"""
"""Switch a session's mode"""
session = self.sessions.get(session_id)
if not session:
raise ValueError(f"Session {session_id} not found")
study_id = session.study_id
session.mode = new_mode
self.store.update_session(session_id, mode=new_mode)
# Terminate existing session
await self._terminate_session(session)
# Rebuild MCP config with new mode
mcp_config = self._build_mcp_config(new_mode)
mcp_config_path = ATOMIZER_ROOT / f".claude-mcp-{session_id}.json"
with open(mcp_config_path, "w") as f:
json.dump(mcp_config, f)
# Create new session with same ID but different mode
return await self.create_session(
mode=new_mode,
study_id=study_id,
resume_session_id=session_id,
)
return session
async def set_study_context(
self,
@@ -322,16 +270,6 @@ class SessionManager:
session.study_id = study_id
self.store.update_session(session_id, study_id=study_id)
# If session is alive, send context update
if session.is_alive() and session.process:
context_update = self.context_builder.build_study_context(study_id)
context_msg = f"[CONTEXT UPDATE] Study changed to: {study_id}\n\n{context_update}"
try:
session.process.stdin.write(f"{context_msg}\n".encode())
await session.process.stdin.drain()
except Exception:
pass # Ignore errors for context updates
def get_session(self, session_id: str) -> Optional[ClaudeSession]:
"""Get session by ID"""
return self.sessions.get(session_id)
@@ -369,20 +307,11 @@ class SessionManager:
},
}
async def _terminate_session(self, session: ClaudeSession):
"""Terminate a Claude session and clean up"""
if session.process and session.is_alive():
session.process.terminate()
try:
await asyncio.wait_for(session.process.wait(), timeout=5.0)
except asyncio.TimeoutError:
session.process.kill()
await session.process.wait()
# Clean up temp files
def _cleanup_session_files(self, session_id: str):
"""Clean up temp files for a session"""
for pattern in [
f".claude-mcp-{session.session_id}.json",
f".claude-prompt-{session.session_id}.md",
f".claude-mcp-{session_id}.json",
f".claude-prompt-{session_id}.md",
]:
path = ATOMIZER_ROOT / pattern
if path.exists():
@@ -391,9 +320,6 @@ class SessionManager:
except Exception:
pass
# Remove from active sessions
self.sessions.pop(session.session_id, None)
async def _cleanup_loop(self):
"""Periodically clean up stale sessions"""
while True:
@@ -404,24 +330,22 @@ class SessionManager:
stale = [
sid
for sid, session in list(self.sessions.items())
if (now - session.last_active).total_seconds() > 3600 # 1 hour
if (now - session.last_active).total_seconds() > 3600
]
for sid in stale:
session = self.sessions.get(sid)
if session:
await self._terminate_session(session)
self._cleanup_session_files(sid)
self.sessions.pop(sid, None)
# Also clean up database
self.store.cleanup_stale_sessions(max_age_hours=24)
except asyncio.CancelledError:
break
except Exception:
pass # Continue cleanup loop on errors
pass
# Global instance for the application
# Global instance
_session_manager: Optional[SessionManager] = None