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:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user