"""~/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)