""" Tmux integration — spawn long-running actions in detached tmux sessions. Each action gets its own session `fester--` so operators can `tmux attach -t fester-build_kernel-abc123` to watch live build output. This is the "action runtime" alternative to running actions inline via subprocess. Set `action["runtime"] = "tmux"` (or `build_env["runtime"]`) to route through TmuxManager instead of run_host. Requires: tmux on PATH. """ import re import shutil import subprocess import time from pathlib import Path from typing import Any, Dict, List, Optional def _have_tmux() -> bool: return shutil.which("tmux") is not None def _safe_session_name(name: str) -> str: """tmux session names can't contain `.` or `:`.""" return re.sub(r"[^a-zA-Z0-9_-]", "_", name)[:200] class TmuxManager: """Manage per-action tmux sessions.""" def __init__(self): self._available = _have_tmux() @property def available(self) -> bool: return self._available def session_name(self, action: Dict[str, Any], build_id: Optional[str] = None) -> str: """Return the canonical session name for an action.""" name = action.get("name", "action") if build_id: return _safe_session_name(f"fester-{name}-{build_id}") return _safe_session_name(f"fester-{name}") def create_session(self, action: Dict[str, Any], cmd: str, cwd: Optional[str] = None, env: Optional[Dict[str, str]] = None, build_id: Optional[str] = None) -> Optional[str]: """Create a detached tmux session running `cmd`. Returns the session name on success, None on failure (or if tmux isn't available).""" if not self._available: return None name = self.session_name(action, build_id) # If session already exists, kill it first (idempotent) if self.session_exists(name): self.kill_session(name) # Build the command with cwd + env wrapping # Use bash -lc so env vars expand correctly env_prefix = "" if env: env_prefix = " ".join(f"{k}={shlex_quote(str(v))}" for k, v in env.items()) + " " full_cmd = f"{env_prefix}{cmd}" if cwd: full_cmd = f"cd {shlex_quote(cwd)} && {full_cmd}" # Run inside bash -lc so the command parses properly argv = [ "tmux", "new-session", "-d", "-s", name, "-x", "200", "-y", "50", # window size "bash", "-lc", full_cmd, ] try: result = subprocess.run(argv, capture_output=True, timeout=10) if result.returncode == 0: return name # Session creation failed return None except (subprocess.TimeoutExpired, FileNotFoundError): return None def session_exists(self, name: str) -> bool: if not self._available: return False try: r = subprocess.run( ["tmux", "has-session", "-t", name], capture_output=True, timeout=2, ) return r.returncode == 0 except (subprocess.TimeoutExpired, FileNotFoundError): return False def attach(self, name: str) -> bool: """Attach to a tmux session. Returns True on success. Note: this blocks until the user detaches.""" if not self._available or not self.session_exists(name): return False try: subprocess.run(["tmux", "attach", "-t", name]) return True except (subprocess.CalledProcessError, FileNotFoundError): return False def kill_session(self, name: str) -> bool: if not self._available: return False try: subprocess.run(["tmux", "kill-session", "-t", name], capture_output=True, timeout=2) return True except (subprocess.TimeoutExpired, FileNotFoundError): return False def capture_output(self, name: str, lines: int = 1000) -> Optional[str]: """Capture the last N lines of tmux pane output.""" if not self._available or not self.session_exists(name): return None try: r = subprocess.run( ["tmux", "capture-pane", "-p", "-S", f"-{lines}", "-t", name], capture_output=True, timeout=3, ) if r.returncode == 0: return r.stdout.decode("utf-8", errors="replace") except (subprocess.TimeoutExpired, FileNotFoundError): pass return None def session_state(self, name: str) -> Optional[Dict[str, Any]]: """Return state info for a session: exists, pane_pid, started_at, etc.""" if not self._available or not self.session_exists(name): return None try: # Get the pane's pid r = subprocess.run( ["tmux", "list-panes", "-t", name, "-F", "#{pane_pid}:#{session_created}:#{pane_current_command}"], capture_output=True, timeout=2, ) if r.returncode != 0: return {"name": name, "exists": True} line = r.stdout.decode().strip().split("\n")[0] parts = line.split(":") return { "name": name, "exists": True, "pane_pid": int(parts[0]) if parts and parts[0].isdigit() else None, "created_at": int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None, "current_command": parts[2] if len(parts) > 2 else None, } except (subprocess.TimeoutExpired, FileNotFoundError, ValueError): return {"name": name, "exists": True} def list_fester_sessions(self) -> List[Dict[str, Any]]: """List all fester-* tmux sessions.""" if not self._available: return [] try: r = subprocess.run( ["tmux", "list-sessions", "-F", "#{session_name}:#{session_created}:#{session_attached}"], capture_output=True, timeout=2, ) if r.returncode != 0: return [] sessions = [] for line in r.stdout.decode().strip().split("\n"): if not line or not line.startswith("fester-"): continue parts = line.split(":") name = parts[0] created = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None attached = parts[2] == "1" if len(parts) > 2 else False state = self.session_state(name) or {} sessions.append({ "name": name, "created_at": created, "attached": attached, "pane_pid": state.get("pane_pid"), "current_command": state.get("current_command"), }) return sessions except (subprocess.TimeoutExpired, FileNotFoundError): return [] def shlex_quote(s: str) -> str: """Minimal shlex.quote replacement (avoids importing shlex for one call).""" if not s: return "''" if re.match(r"^[A-Za-z0-9_@%+=:,./-]+$", s): return s return "'" + s.replace("'", "'\"'\"'") + "'"