feat: deliver installable local alpha sessions
Some checks failed
tamq-ci / test (push) Failing after 6s
Some checks failed
tamq-ci / test (push) Failing after 6s
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a03397-4d51-7fd1-8ff2-946eb22ea2bc
This commit is contained in:
parent
d56bd3c79f
commit
5774fbd548
18 changed files with 890 additions and 94 deletions
155
src/tamq/tmux.py
155
src/tamq/tmux.py
|
|
@ -2,9 +2,12 @@ from __future__ import annotations
|
|||
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
from uuid import uuid4
|
||||
import subprocess
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .registry import repository_paths, validate_targets
|
||||
|
||||
|
|
@ -19,6 +22,8 @@ class Endpoint:
|
|||
pid: int
|
||||
repos: list[str]
|
||||
instance_id: str = ""
|
||||
created_session: bool = False
|
||||
created_windows: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def endpoint_id(self) -> str:
|
||||
|
|
@ -29,48 +34,154 @@ class Endpoint:
|
|||
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"):
|
||||
def __init__(
|
||||
self,
|
||||
session: str = "tamq",
|
||||
*,
|
||||
tmux_command: Sequence[str] | None = None,
|
||||
tamq_command: Sequence[str] | 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"]))
|
||||
self.tamq_command = tuple(tamq_command or ["tamq"])
|
||||
|
||||
def _run(self, *args: str, check: bool = True) -> str:
|
||||
result = subprocess.run(["tmux", *args], text=True, capture_output=True, check=False)
|
||||
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 ensure(self, repos: list[str], command: str = "codex") -> Endpoint:
|
||||
if not repos:
|
||||
raise TmuxError("at least one repository is required")
|
||||
try:
|
||||
validate_targets(repos)
|
||||
except Exception as exc:
|
||||
raise TmuxError(str(exc)) from exc
|
||||
result = subprocess.run(["tmux", "has-session", "-t", self.session], capture_output=True)
|
||||
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:
|
||||
self._run("new-session", "-d", "-s", self.session, "-n", "__tamq_boot", "-c", os.getcwd(), "sleep", "infinity")
|
||||
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
|
||||
endpoint = Endpoint(self.session, pid, list(dict.fromkeys(repos)), f"tmux-amq-{pid}-{uuid4().hex[:12]}")
|
||||
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 = "codex") -> 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")
|
||||
try:
|
||||
command_parts = tuple(shlex.split(command))
|
||||
except ValueError as exc:
|
||||
raise TmuxError(f"invalid agent command: {exc}") from exc
|
||||
if not command_parts:
|
||||
raise TmuxError("agent command must not be empty")
|
||||
if shutil.which(command_parts[0]) is None:
|
||||
raise TmuxError(f"agent 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()
|
||||
windows = self._run("list-windows", "-t", self.session, "-F", "#{window_name}").splitlines()
|
||||
for index, repo in enumerate(endpoint.repos):
|
||||
if repo not in windows:
|
||||
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 = "codex") -> Endpoint:
|
||||
return self.ensure_plan(self.preflight(repos, command))
|
||||
|
||||
def ensure_plan(self, plan: LaunchPlan) -> Endpoint:
|
||||
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",
|
||||
"-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()
|
||||
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:
|
||||
try:
|
||||
repo_path = paths[repo]
|
||||
except KeyError as exc:
|
||||
raise TmuxError(f"gita path missing for registered repository: {repo}") from exc
|
||||
self._run("new-window", "-d", "-t", self.session, "-n", repo, "-c", repo_path, "sleep", "infinity")
|
||||
tap = ["tamq", "tap", "--repo", repo, "--endpoint", endpoint.endpoint_id, "--", command]
|
||||
self._run("new-window", "-d", "-t", self.session, "-n", repo, "-c", plan.paths[repo])
|
||||
windows.append(repo)
|
||||
created_windows.append(repo)
|
||||
tap = [
|
||||
*self.tamq_command,
|
||||
"tap", "--repo", repo, "--endpoint", endpoint_id, "--", *plan.command,
|
||||
]
|
||||
self._run("send-keys", "-t", f"{self.session}:{repo}", shell_join(tap), "C-m")
|
||||
return endpoint
|
||||
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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue