Some checks failed
tamq-ci / test (push) Failing after 5s
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a03397-4d51-7fd1-8ff2-946eb22ea2bc
263 lines
11 KiB
Python
263 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
from uuid import uuid4
|
|
import subprocess
|
|
from collections.abc import Sequence
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from .config import state_dir
|
|
from .registry import repository_paths, validate_targets
|
|
|
|
|
|
class TmuxError(RuntimeError):
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class Endpoint:
|
|
session: str
|
|
pid: int
|
|
repos: list[str]
|
|
instance_id: str = ""
|
|
created_session: bool = False
|
|
created_windows: tuple[str, ...] = ()
|
|
|
|
@property
|
|
def endpoint_id(self) -> str:
|
|
return f"tmux-amq-{self.pid}"
|
|
|
|
@property
|
|
def instance_key(self) -> str:
|
|
return self.instance_id or self.endpoint_id
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LaunchPlan:
|
|
repos: tuple[str, ...]
|
|
paths: dict[str, str]
|
|
command: tuple[str, ...]
|
|
|
|
|
|
class TmuxManager:
|
|
def __init__(
|
|
self,
|
|
session: str = "tamq",
|
|
*,
|
|
tmux_command: Sequence[str] | None = None,
|
|
tamq_command: Sequence[str] | None = None,
|
|
command_dir: Path | None = None,
|
|
):
|
|
self.session = session
|
|
socket_name = os.environ.get("TAMQ_TMUX_SOCKET")
|
|
self.tmux_command = tuple(tmux_command or (["tmux", "-L", socket_name] if socket_name else ["tmux"]))
|
|
if tamq_command is None:
|
|
self.tamq_command = (shutil.which("tamq") or "tamq",)
|
|
else:
|
|
self.tamq_command = tuple(tamq_command)
|
|
configured_command_dir = os.environ.get("TAMQ_COMMAND_DIR")
|
|
if command_dir is not None:
|
|
self.command_dir = command_dir
|
|
elif configured_command_dir:
|
|
self.command_dir = Path(configured_command_dir)
|
|
elif (
|
|
len(self.tamq_command) == 1
|
|
and Path(self.tamq_command[0]).is_absolute()
|
|
and os.access(Path(self.tamq_command[0]).parent, os.W_OK | os.X_OK)
|
|
):
|
|
self.command_dir = Path(self.tamq_command[0]).parent
|
|
else:
|
|
self.command_dir = state_dir() / "commands" / session
|
|
|
|
def _install_address_commands(self, repos: Sequence[str]) -> None:
|
|
"""Install tamq-owned commands without modifying a user's shell files."""
|
|
self.command_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
if not os.access(self.command_dir, os.W_OK | os.X_OK):
|
|
raise TmuxError(f"address command directory is not writable: {self.command_dir}")
|
|
for repo in repos:
|
|
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", repo) is None:
|
|
raise TmuxError(
|
|
f"repository name cannot be exposed as a shell command: {repo!r}"
|
|
)
|
|
target = f"@{repo}:"
|
|
script = (
|
|
"#!/bin/sh\n"
|
|
"# tamq-address-command v1\n"
|
|
f"exec {shell_join(list(self.tamq_command))} send -- {shlex.quote(target)} \"$@\"\n"
|
|
)
|
|
for name in (f"@{repo}", target):
|
|
destination = self.command_dir / name
|
|
if destination.exists() or destination.is_symlink():
|
|
if destination.is_symlink() or not destination.is_file():
|
|
raise TmuxError(f"refusing to replace existing command: {destination}")
|
|
try:
|
|
existing = destination.read_text(encoding="utf-8")
|
|
except OSError as exc:
|
|
raise TmuxError(f"cannot inspect existing command: {destination}") from exc
|
|
if "# tamq-address-command v1" not in existing:
|
|
raise TmuxError(f"refusing to replace existing command: {destination}")
|
|
temporary = self.command_dir / f".{name}.{uuid4().hex}.tmp"
|
|
temporary.write_text(script, encoding="utf-8")
|
|
temporary.chmod(0o700)
|
|
temporary.replace(destination)
|
|
|
|
def _window_environment(self, repo: str) -> tuple[str, ...]:
|
|
path = os.environ.get("PATH", "")
|
|
command_path = f"{self.command_dir}{os.pathsep}{path}" if path else str(self.command_dir)
|
|
return "-e", f"TAMQ_REPO={repo}", "-e", f"PATH={command_path}"
|
|
|
|
def _run(self, *args: str, check: bool = True) -> str:
|
|
result = subprocess.run([*self.tmux_command, *args], text=True, capture_output=True, check=False)
|
|
if check and result.returncode:
|
|
raise TmuxError(result.stderr.strip() or "tmux command failed")
|
|
return result.stdout.strip()
|
|
|
|
def _existing_session_identity(self) -> tuple[int, str] | None:
|
|
result = subprocess.run(
|
|
[*self.tmux_command, "has-session", "-t", self.session],
|
|
capture_output=True,
|
|
)
|
|
if result.returncode != 0:
|
|
return None
|
|
pid_text = self._run("display-message", "-p", "-t", self.session, "#{pid}")
|
|
try:
|
|
pid = int(pid_text)
|
|
except ValueError as exc:
|
|
raise TmuxError(f"unable to determine tmux PID: {pid_text!r}") from exc
|
|
instance_id = self._run(
|
|
"show-options", "-v", "-t", self.session, "@tamq_instance_id", check=False,
|
|
)
|
|
managed = self._run(
|
|
"show-options", "-v", "-t", self.session, "@tamq_managed", check=False,
|
|
)
|
|
if managed != "1" and not instance_id.startswith(f"tmux-amq-{pid}-"):
|
|
raise TmuxError(
|
|
f"tmux session already exists and is not managed by tamq: {self.session}"
|
|
)
|
|
return pid, instance_id
|
|
|
|
def preflight(self, repos: list[str], command: str | None = None) -> LaunchPlan:
|
|
if not repos:
|
|
raise TmuxError("at least one repository is required")
|
|
if not self.tmux_command or shutil.which(self.tmux_command[0]) is None:
|
|
raise TmuxError("tmux is required; install it and ensure 'tmux' is on PATH")
|
|
if shutil.which("gita") is None:
|
|
raise TmuxError("gita is required; install it and ensure 'gita' is on PATH")
|
|
command_parts: tuple[str, ...] = ()
|
|
if command is not None:
|
|
try:
|
|
command_parts = tuple(shlex.split(command))
|
|
except ValueError as exc:
|
|
raise TmuxError(f"invalid initial command: {exc}") from exc
|
|
if not command_parts:
|
|
raise TmuxError("initial command must not be empty")
|
|
if shutil.which(command_parts[0]) is None:
|
|
raise TmuxError(f"initial command not found on PATH: {command_parts[0]}")
|
|
unique_repos = tuple(dict.fromkeys(repos))
|
|
try:
|
|
validate_targets(list(unique_repos))
|
|
except Exception as exc:
|
|
raise TmuxError(str(exc)) from exc
|
|
paths = repository_paths()
|
|
selected_paths: dict[str, str] = {}
|
|
for repo in unique_repos:
|
|
try:
|
|
repo_path = paths[repo]
|
|
except KeyError as exc:
|
|
raise TmuxError(f"gita path missing for registered repository: {repo}") from exc
|
|
if not Path(repo_path).is_dir():
|
|
raise TmuxError(f"gita path is not a directory for registered repository {repo}: {repo_path}")
|
|
selected_paths[repo] = repo_path
|
|
self._existing_session_identity()
|
|
return LaunchPlan(unique_repos, selected_paths, command_parts)
|
|
|
|
def ensure(self, repos: list[str], command: str | None = None) -> Endpoint:
|
|
return self.ensure_plan(self.preflight(repos, command))
|
|
|
|
def ensure_plan(self, plan: LaunchPlan, *, tap: bool = False) -> Endpoint:
|
|
if tap and not plan.command:
|
|
raise TmuxError("PTY tap requires an explicit initial command")
|
|
created_session = False
|
|
created_windows: list[str] = []
|
|
try:
|
|
identity = self._existing_session_identity()
|
|
if identity is None:
|
|
first_repo = plan.repos[0]
|
|
self._run(
|
|
"new-session", "-d", "-s", self.session, "-n", "__tamq_boot",
|
|
*self._window_environment(first_repo),
|
|
"-c", plan.paths[first_repo],
|
|
)
|
|
created_session = True
|
|
pid_text = self._run("display-message", "-p", "-t", self.session, "#{pid}")
|
|
try:
|
|
pid = int(pid_text)
|
|
except ValueError as exc:
|
|
raise TmuxError(f"unable to determine tmux PID: {pid_text!r}") from exc
|
|
instance_id = ""
|
|
else:
|
|
pid, instance_id = identity
|
|
endpoint_id = f"tmux-amq-{pid}"
|
|
if not instance_id.startswith(f"{endpoint_id}-"):
|
|
instance_id = f"{endpoint_id}-{uuid4().hex[:12]}"
|
|
self._run("set-option", "-t", self.session, "@tamq_instance_id", instance_id)
|
|
self._run("set-option", "-t", self.session, "@tamq_managed", "1")
|
|
windows = self._run("list-windows", "-t", self.session, "-F", "#{window_name}").splitlines()
|
|
addressable_repos = [repo for repo in windows if repo != "__tamq_boot"]
|
|
addressable_repos.extend(repo for repo in plan.repos if repo not in addressable_repos)
|
|
self._install_address_commands(addressable_repos)
|
|
command_path = self._window_environment(plan.repos[0])[-1].removeprefix("PATH=")
|
|
self._run("set-environment", "-t", self.session, "PATH", command_path)
|
|
self._run("set-option", "-t", self.session, "@tamq_command_dir", str(self.command_dir))
|
|
for index, repo in enumerate(plan.repos):
|
|
if repo in windows:
|
|
continue
|
|
if index == 0 and "__tamq_boot" in windows:
|
|
if not created_session:
|
|
self._run("respawn-pane", "-k", "-t", f"{self.session}:__tamq_boot", "-c", plan.paths[repo])
|
|
self._run("rename-window", "-t", f"{self.session}:__tamq_boot", repo)
|
|
windows[windows.index("__tamq_boot")] = repo
|
|
else:
|
|
self._run(
|
|
"new-window", "-d", "-t", self.session, "-n", repo,
|
|
*self._window_environment(repo), "-c", plan.paths[repo],
|
|
)
|
|
windows.append(repo)
|
|
created_windows.append(repo)
|
|
if plan.command:
|
|
command = (
|
|
[
|
|
*self.tamq_command,
|
|
"tap", "--repo", repo, "--endpoint", endpoint_id, "--", *plan.command,
|
|
]
|
|
if tap
|
|
else list(plan.command)
|
|
)
|
|
self._run("send-keys", "-t", f"{self.session}:{repo}", shell_join(command), "C-m")
|
|
self._run("select-window", "-t", f"{self.session}:{plan.repos[0]}")
|
|
return Endpoint(
|
|
self.session,
|
|
pid,
|
|
list(plan.repos),
|
|
instance_id,
|
|
created_session,
|
|
tuple(created_windows),
|
|
)
|
|
except Exception:
|
|
self.rollback(Endpoint(self.session, 0, list(plan.repos), created_session=created_session, created_windows=tuple(created_windows)))
|
|
raise
|
|
|
|
def rollback(self, endpoint: Endpoint) -> None:
|
|
if endpoint.created_session:
|
|
self._run("kill-session", "-t", endpoint.session, check=False)
|
|
return
|
|
for window in reversed(endpoint.created_windows):
|
|
self._run("kill-window", "-t", f"{endpoint.session}:{window}", check=False)
|
|
|
|
|
|
def shell_join(parts: list[str]) -> str:
|
|
return " ".join(shlex.quote(part) for part in parts)
|