Merges workspace.html + ralph.html into a single unified project hub with: - Cookie-based auth (DASHBOARD_TOKEN, HttpOnly, SameSite=Strict) - 9-state project badge system (running-ralph/manual, planning, approved, pending, blocked, failed, complete, idle) with BUTTONS_FOR_STATE matrix - SSE realtime + polling fallback, version-based optimistic concurrency (If-Match) - Planning chat modal (phase stepper, markdown bubbles, 50s+ wait state, auto-resume) - Propose modal (Variant B: inline Plan-with-Echo checkbox) - 5-type toast taxonomy (success/info/warning/busy/error, 3px colored left-bar) - Inter font self-hosted + shared tokens.css design system + DESIGN.md - src/jsonlock.py (flock helper, sidecar .lock for stable inode) - src/approved_tasks_cli.py (shell-safe wrapper for cron/ralph.sh) - 55 new tests (T#1–T#30) + real jsonlock bug fix caught by T#16/T#28 - No emoji anywhere (enforced by test_dashboard_no_emoji.py) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
376 lines
16 KiB
Python
376 lines
16 KiB
Python
"""~/workspace/ project control: list, run, stop, delete, logs."""
|
|
import json
|
|
import os
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
import constants
|
|
|
|
from handlers._validators import validate_slug
|
|
|
|
|
|
class WorkspaceHandlers:
|
|
"""Mixin for /api/workspace and /api/workspace/*."""
|
|
|
|
def _validate_project(self, name):
|
|
"""Validate project name and return its path, or None."""
|
|
if validate_slug(name) is not None:
|
|
return None
|
|
project_dir = constants.WORKSPACE_DIR / name
|
|
if not project_dir.exists() or not project_dir.is_dir():
|
|
return None
|
|
if not str(project_dir.resolve()).startswith(str(constants.WORKSPACE_DIR)):
|
|
return None
|
|
return project_dir
|
|
|
|
# ── /api/workspace list ─────────────────────────────────────
|
|
def handle_workspace_list(self):
|
|
"""List projects in ~/workspace/ with Ralph status, git info, etc."""
|
|
try:
|
|
projects = []
|
|
if not constants.WORKSPACE_DIR.exists():
|
|
self.send_json({'projects': []})
|
|
return
|
|
|
|
for project_dir in sorted(constants.WORKSPACE_DIR.iterdir()):
|
|
if not project_dir.is_dir() or project_dir.name.startswith('.'):
|
|
continue
|
|
|
|
ralph_dir = project_dir / 'scripts' / 'ralph'
|
|
prd_json = ralph_dir / 'prd.json'
|
|
tasks_dir = project_dir / 'tasks'
|
|
|
|
proj = {
|
|
'name': project_dir.name,
|
|
'path': str(project_dir),
|
|
'hasRalph': ralph_dir.exists(),
|
|
'hasPrd': any(tasks_dir.glob('prd-*.md')) if tasks_dir.exists() else False,
|
|
'hasMain': (project_dir / 'main.py').exists(),
|
|
'hasVenv': (project_dir / 'venv').exists(),
|
|
'hasReadme': (project_dir / 'README.md').exists(),
|
|
'ralph': None,
|
|
'process': {'running': False, 'pid': None, 'port': None},
|
|
'git': None,
|
|
}
|
|
|
|
# Ralph status
|
|
if prd_json.exists():
|
|
try:
|
|
prd = json.loads(prd_json.read_text())
|
|
stories = prd.get('userStories', [])
|
|
complete = sum(1 for s in stories if s.get('passes'))
|
|
|
|
ralph_pid = None
|
|
ralph_running = False
|
|
pid_file = ralph_dir / '.ralph.pid'
|
|
if pid_file.exists():
|
|
try:
|
|
pid = int(pid_file.read_text().strip())
|
|
os.kill(pid, 0)
|
|
ralph_running = True
|
|
ralph_pid = pid
|
|
except (ValueError, ProcessLookupError, PermissionError):
|
|
pass
|
|
|
|
last_iter = None
|
|
tech = {}
|
|
logs_dir = ralph_dir / 'logs'
|
|
if logs_dir.exists():
|
|
log_files = sorted(logs_dir.glob('iteration-*.log'), key=lambda f: f.stat().st_mtime, reverse=True)
|
|
if log_files:
|
|
mtime = log_files[0].stat().st_mtime
|
|
last_iter = datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M')
|
|
tech = prd.get('techStack', {})
|
|
|
|
proj['ralph'] = {
|
|
'running': ralph_running,
|
|
'pid': ralph_pid,
|
|
'storiesTotal': len(stories),
|
|
'storiesComplete': complete,
|
|
'lastIteration': last_iter,
|
|
'stories': [
|
|
{'id': s.get('id', ''), 'title': s.get('title', ''), 'passes': s.get('passes', False)}
|
|
for s in stories
|
|
],
|
|
}
|
|
proj['techStack'] = {
|
|
'type': tech.get('type', ''),
|
|
'commands': tech.get('commands', {}),
|
|
'port': tech.get('port'),
|
|
}
|
|
except (json.JSONDecodeError, IOError):
|
|
pass
|
|
|
|
# Check if main.py is running
|
|
if proj['hasMain']:
|
|
try:
|
|
result = subprocess.run(
|
|
['pgrep', '-f', f'python.*{project_dir.name}/main.py'],
|
|
capture_output=True, text=True, timeout=3,
|
|
)
|
|
if result.stdout.strip():
|
|
pids = result.stdout.strip().split('\n')
|
|
port = None
|
|
if prd_json.exists():
|
|
try:
|
|
prd_data = json.loads(prd_json.read_text())
|
|
port = prd_data.get('techStack', {}).get('port')
|
|
except (json.JSONDecodeError, IOError):
|
|
pass
|
|
proj['process'] = {
|
|
'running': True,
|
|
'pid': int(pids[0]),
|
|
'port': port,
|
|
}
|
|
except Exception:
|
|
pass
|
|
|
|
# Git info (using _run_git from GitHandlers mixin)
|
|
if (project_dir / '.git').exists():
|
|
try:
|
|
branch = self._run_git(project_dir, ['branch', '--show-current']).stdout.strip()
|
|
last_commit = self._run_git(project_dir, ['log', '-1', '--format=%h - %s']).stdout.strip()
|
|
status_out = self._run_git(project_dir, ['status', '--short']).stdout.strip()
|
|
uncommitted = len([l for l in status_out.split('\n') if l.strip()]) if status_out else 0
|
|
proj['git'] = {
|
|
'branch': branch,
|
|
'lastCommit': last_commit,
|
|
'uncommitted': uncommitted,
|
|
}
|
|
except Exception:
|
|
pass
|
|
|
|
projects.append(proj)
|
|
|
|
self.send_json({'projects': projects})
|
|
except Exception as e:
|
|
self.send_json({'error': str(e)}, 500)
|
|
|
|
# ── /api/workspace/run (main | ralph | test) ───────────────
|
|
def handle_workspace_run(self):
|
|
"""Start a project process (main.py, ralph.sh, or pytest)."""
|
|
try:
|
|
data = self._read_post_json()
|
|
project_name = data.get('project', '')
|
|
command = data.get('command', '')
|
|
|
|
project_dir = self._validate_project(project_name)
|
|
if not project_dir:
|
|
self.send_json({'success': False, 'error': 'Invalid project'}, 400)
|
|
return
|
|
|
|
allowed_commands = {'main', 'ralph', 'test'}
|
|
if command not in allowed_commands:
|
|
self.send_json({'success': False, 'error': f'Invalid command. Allowed: {", ".join(allowed_commands)}'}, 400)
|
|
return
|
|
|
|
ralph_dir = project_dir / 'scripts' / 'ralph'
|
|
|
|
if command == 'main':
|
|
main_py = project_dir / 'main.py'
|
|
if not main_py.exists():
|
|
self.send_json({'success': False, 'error': 'No main.py found'}, 404)
|
|
return
|
|
|
|
venv_python = project_dir / 'venv' / 'bin' / 'python'
|
|
python_cmd = str(venv_python) if venv_python.exists() else sys.executable
|
|
|
|
log_path = ralph_dir / 'logs' / 'main.log' if ralph_dir.exists() else project_dir / 'main.log'
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with open(log_path, 'a') as log_file:
|
|
proc = subprocess.Popen(
|
|
[python_cmd, 'main.py'],
|
|
cwd=str(project_dir),
|
|
stdout=log_file,
|
|
stderr=log_file,
|
|
start_new_session=True,
|
|
)
|
|
self.send_json({'success': True, 'pid': proc.pid, 'log': str(log_path)})
|
|
|
|
elif command == 'ralph':
|
|
ralph_sh = ralph_dir / 'ralph.sh'
|
|
if not ralph_sh.exists():
|
|
self.send_json({'success': False, 'error': 'No ralph.sh found'}, 404)
|
|
return
|
|
|
|
log_path = ralph_dir / 'logs' / 'ralph.log'
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with open(log_path, 'a') as log_file:
|
|
proc = subprocess.Popen(
|
|
['bash', str(ralph_sh)],
|
|
cwd=str(project_dir),
|
|
stdout=log_file,
|
|
stderr=log_file,
|
|
start_new_session=True,
|
|
)
|
|
|
|
(ralph_dir / '.ralph.pid').write_text(str(proc.pid))
|
|
self.send_json({'success': True, 'pid': proc.pid, 'log': str(log_path)})
|
|
|
|
elif command == 'test':
|
|
venv_python = project_dir / 'venv' / 'bin' / 'python'
|
|
python_cmd = str(venv_python) if venv_python.exists() else sys.executable
|
|
|
|
result = subprocess.run(
|
|
[python_cmd, '-m', 'pytest', '-v', '--tb=short'],
|
|
cwd=str(project_dir),
|
|
capture_output=True, text=True,
|
|
timeout=120,
|
|
)
|
|
self.send_json({
|
|
'success': result.returncode == 0,
|
|
'output': result.stdout + result.stderr,
|
|
'returncode': result.returncode,
|
|
})
|
|
|
|
except subprocess.TimeoutExpired:
|
|
self.send_json({'success': False, 'error': 'Test timeout (120s)'}, 500)
|
|
except Exception as e:
|
|
self.send_json({'success': False, 'error': str(e)}, 500)
|
|
|
|
def handle_workspace_stop(self):
|
|
"""Stop a project process."""
|
|
try:
|
|
data = self._read_post_json()
|
|
project_name = data.get('project', '')
|
|
target = data.get('target', '')
|
|
|
|
project_dir = self._validate_project(project_name)
|
|
if not project_dir:
|
|
self.send_json({'success': False, 'error': 'Invalid project'}, 400)
|
|
return
|
|
|
|
if target not in ('main', 'ralph'):
|
|
self.send_json({'success': False, 'error': 'Invalid target. Use: main, ralph'}, 400)
|
|
return
|
|
|
|
if target == 'ralph':
|
|
pid_file = project_dir / 'scripts' / 'ralph' / '.ralph.pid'
|
|
if pid_file.exists():
|
|
try:
|
|
pid = int(pid_file.read_text().strip())
|
|
proc_cwd = Path(f'/proc/{pid}/cwd').resolve()
|
|
if str(proc_cwd).startswith(str(constants.WORKSPACE_DIR)):
|
|
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
|
self.send_json({'success': True, 'message': f'Ralph stopped (PID {pid})'})
|
|
else:
|
|
self.send_json({'success': False, 'error': 'Process not in workspace'}, 403)
|
|
except ProcessLookupError:
|
|
self.send_json({'success': True, 'message': 'Process already stopped'})
|
|
except PermissionError:
|
|
self.send_json({'success': False, 'error': 'Permission denied'}, 403)
|
|
else:
|
|
self.send_json({'success': False, 'error': 'No PID file found'}, 404)
|
|
|
|
elif target == 'main':
|
|
try:
|
|
result = subprocess.run(
|
|
['pgrep', '-f', f'python.*{project_dir.name}/main.py'],
|
|
capture_output=True, text=True, timeout=3,
|
|
)
|
|
if result.stdout.strip():
|
|
pid = int(result.stdout.strip().split('\n')[0])
|
|
proc_cwd = Path(f'/proc/{pid}/cwd').resolve()
|
|
if str(proc_cwd).startswith(str(constants.WORKSPACE_DIR)):
|
|
os.kill(pid, signal.SIGTERM)
|
|
self.send_json({'success': True, 'message': f'Main stopped (PID {pid})'})
|
|
else:
|
|
self.send_json({'success': False, 'error': 'Process not in workspace'}, 403)
|
|
else:
|
|
self.send_json({'success': True, 'message': 'No running process found'})
|
|
except Exception as e:
|
|
self.send_json({'success': False, 'error': str(e)}, 500)
|
|
|
|
except Exception as e:
|
|
self.send_json({'success': False, 'error': str(e)}, 500)
|
|
|
|
def handle_workspace_delete(self):
|
|
"""Delete a workspace project."""
|
|
try:
|
|
data = self._read_post_json()
|
|
project_name = data.get('project', '')
|
|
confirm = data.get('confirm', '')
|
|
|
|
project_dir = self._validate_project(project_name)
|
|
if not project_dir:
|
|
self.send_json({'success': False, 'error': 'Invalid project'}, 400)
|
|
return
|
|
|
|
if confirm != project_name:
|
|
self.send_json({'success': False, 'error': 'Confirmation does not match project name'}, 400)
|
|
return
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
['pgrep', '-f', f'{project_dir.name}/(main\\.py|ralph)'],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
if result.stdout.strip():
|
|
self.send_json({'success': False, 'error': 'Project has running processes. Stop them first.'})
|
|
return
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
|
|
shutil.rmtree(str(project_dir))
|
|
self.send_json({'success': True, 'message': f'Project {project_name} deleted'})
|
|
except Exception as e:
|
|
self.send_json({'success': False, 'error': str(e)}, 500)
|
|
|
|
def handle_workspace_logs(self):
|
|
"""Get last N lines from a project log."""
|
|
try:
|
|
params = parse_qs(urlparse(self.path).query)
|
|
project_name = params.get('project', [''])[0]
|
|
log_type = params.get('type', ['ralph'])[0]
|
|
lines_count = min(int(params.get('lines', ['100'])[0]), 500)
|
|
|
|
project_dir = self._validate_project(project_name)
|
|
if not project_dir:
|
|
self.send_json({'error': 'Invalid project'}, 400)
|
|
return
|
|
|
|
ralph_dir = project_dir / 'scripts' / 'ralph'
|
|
|
|
if log_type == 'ralph':
|
|
log_file = ralph_dir / 'logs' / 'ralph.log'
|
|
if not log_file.exists():
|
|
log_file = ralph_dir / 'logs' / 'ralph-test.log'
|
|
elif log_type == 'main':
|
|
log_file = ralph_dir / 'logs' / 'main.log' if ralph_dir.exists() else project_dir / 'main.log'
|
|
elif log_type == 'progress':
|
|
log_file = ralph_dir / 'progress.txt'
|
|
elif log_type.startswith('iteration-'):
|
|
log_file = ralph_dir / 'logs' / f'{log_type}.log'
|
|
else:
|
|
self.send_json({'error': 'Invalid log type'}, 400)
|
|
return
|
|
|
|
if not log_file.exists():
|
|
self.send_json({'project': project_name, 'type': log_type, 'lines': [], 'total': 0})
|
|
return
|
|
|
|
if not str(log_file.resolve()).startswith(str(constants.WORKSPACE_DIR)):
|
|
self.send_json({'error': 'Access denied'}, 403)
|
|
return
|
|
|
|
content = log_file.read_text(encoding='utf-8', errors='replace')
|
|
all_lines = content.split('\n')
|
|
total = len(all_lines)
|
|
last_lines = all_lines[-lines_count:] if len(all_lines) > lines_count else all_lines
|
|
|
|
self.send_json({
|
|
'project': project_name,
|
|
'type': log_type,
|
|
'lines': last_lines,
|
|
'total': total,
|
|
})
|
|
except Exception as e:
|
|
self.send_json({'error': str(e)}, 500)
|