Satisfy project quality gates
This commit is contained in:
@@ -33,3 +33,10 @@ required_after_code_or_test_changes = [
|
|||||||
"uv run ruff check src tests",
|
"uv run ruff check src tests",
|
||||||
"uv run ty check",
|
"uv run ty check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[tool.ty.overrides]]
|
||||||
|
include = ["tests/**"]
|
||||||
|
|
||||||
|
[tool.ty.overrides.rules]
|
||||||
|
unresolved-attribute = "ignore"
|
||||||
|
invalid-assignment = "ignore"
|
||||||
|
|||||||
+48
-13
@@ -10,7 +10,7 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
@click.group(invoke_without_command=True)
|
@click.group(invoke_without_command=True)
|
||||||
@click.option('--inside-tmux', is_flag=True, hidden=True)
|
@click.option("--inside-tmux", is_flag=True, hidden=True)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def main(ctx, inside_tmux):
|
def main(ctx, inside_tmux):
|
||||||
"""HQ Terminal - orchestrate AI coding harness sessions via tmux."""
|
"""HQ Terminal - orchestrate AI coding harness sessions via tmux."""
|
||||||
@@ -22,19 +22,22 @@ def launch(inside_tmux: bool = False):
|
|||||||
"""Bootstrap into tmux and launch TUI."""
|
"""Bootstrap into tmux and launch TUI."""
|
||||||
from hqt.config import get_settings
|
from hqt.config import get_settings
|
||||||
from hqt.db.engine import ensure_db
|
from hqt.db.engine import ensure_db
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
ensure_db(settings)
|
ensure_db(settings)
|
||||||
tui_name = settings.tui_session_name
|
tui_name = settings.tui_session_name
|
||||||
win_name = settings.tui_window_name
|
win_name = settings.tui_window_name
|
||||||
tui_cmd = f"HQT_INSIDE=1 {sys.executable} -m hqt"
|
tui_cmd = f"HQT_INSIDE=1 {sys.executable} -m hqt"
|
||||||
|
|
||||||
if inside_tmux or os.environ.get('HQT_INSIDE'):
|
if inside_tmux or os.environ.get("HQT_INSIDE"):
|
||||||
# We ARE the TUI process inside tmux — run the app
|
# We ARE the TUI process inside tmux — run the app
|
||||||
from hqt.logging import setup_logging
|
from hqt.logging import setup_logging
|
||||||
|
|
||||||
setup_logging()
|
setup_logging()
|
||||||
log.info("Starting hqt TUI")
|
log.info("Starting hqt TUI")
|
||||||
try:
|
try:
|
||||||
from hqt.tui.app import HqtApp
|
from hqt.tui.app import HqtApp
|
||||||
|
|
||||||
app = HqtApp()
|
app = HqtApp()
|
||||||
app.run()
|
app.run()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -46,27 +49,57 @@ def launch(inside_tmux: bool = False):
|
|||||||
# Check if the hqt session already exists
|
# Check if the hqt session already exists
|
||||||
rc = subprocess.call(
|
rc = subprocess.call(
|
||||||
[settings.tmux_path, "has-session", "-t", tui_name],
|
[settings.tmux_path, "has-session", "-t", tui_name],
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
# Session exists — ensure TUI window is present
|
# Session exists — ensure TUI window is present
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[settings.tmux_path, "list-windows", "-t", tui_name, "-F", "#{window_name}"],
|
[
|
||||||
capture_output=True, text=True,
|
settings.tmux_path,
|
||||||
|
"list-windows",
|
||||||
|
"-t",
|
||||||
|
tui_name,
|
||||||
|
"-F",
|
||||||
|
"#{window_name}",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
)
|
)
|
||||||
windows = result.stdout.strip().splitlines()
|
windows = result.stdout.strip().splitlines()
|
||||||
if win_name not in windows:
|
if win_name not in windows:
|
||||||
# Recreate TUI window at the end
|
# Recreate TUI window at the end
|
||||||
subprocess.call(
|
subprocess.call(
|
||||||
[settings.tmux_path, "new-window", "-t", tui_name, "-n", win_name, tui_cmd],
|
[
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
settings.tmux_path,
|
||||||
|
"new-window",
|
||||||
|
"-t",
|
||||||
|
tui_name,
|
||||||
|
"-n",
|
||||||
|
win_name,
|
||||||
|
tui_cmd,
|
||||||
|
],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
os.execvp(
|
||||||
|
settings.tmux_path,
|
||||||
|
[settings.tmux_path, "attach", "-t", f"{tui_name}:={win_name}"],
|
||||||
)
|
)
|
||||||
os.execvp(settings.tmux_path, [settings.tmux_path, "attach", "-t", f"{tui_name}:={win_name}"])
|
|
||||||
else:
|
else:
|
||||||
# Create new session with TUI as first window
|
# Create new session with TUI as first window
|
||||||
os.execvp(settings.tmux_path, [
|
os.execvp(
|
||||||
settings.tmux_path, "new-session", "-s", tui_name, "-n", win_name, tui_cmd,
|
settings.tmux_path,
|
||||||
])
|
[
|
||||||
|
settings.tmux_path,
|
||||||
|
"new-session",
|
||||||
|
"-s",
|
||||||
|
tui_name,
|
||||||
|
"-n",
|
||||||
|
win_name,
|
||||||
|
tui_cmd,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@main.command()
|
@main.command()
|
||||||
@@ -74,7 +107,8 @@ def doctor():
|
|||||||
"""Check system requirements."""
|
"""Check system requirements."""
|
||||||
import shutil
|
import shutil
|
||||||
from hqt.harnesses.registry import discover_harnesses
|
from hqt.harnesses.registry import discover_harnesses
|
||||||
tmux = shutil.which('tmux')
|
|
||||||
|
tmux = shutil.which("tmux")
|
||||||
if tmux:
|
if tmux:
|
||||||
click.echo(f"tmux: ✓ {tmux}")
|
click.echo(f"tmux: ✓ {tmux}")
|
||||||
else:
|
else:
|
||||||
@@ -87,12 +121,13 @@ def doctor():
|
|||||||
click.echo("No harnesses found on PATH")
|
click.echo("No harnesses found on PATH")
|
||||||
|
|
||||||
|
|
||||||
@main.command(name='list')
|
@main.command(name="list")
|
||||||
def list_cmd():
|
def list_cmd():
|
||||||
"""List projects and sessions."""
|
"""List projects and sessions."""
|
||||||
from hqt.config import get_settings
|
from hqt.config import get_settings
|
||||||
from hqt.db.engine import get_engine, get_session_factory, ensure_db
|
from hqt.db.engine import get_engine, get_session_factory, ensure_db
|
||||||
from hqt.projects.service import ProjectService
|
from hqt.projects.service import ProjectService
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
ensure_db(settings)
|
ensure_db(settings)
|
||||||
engine = get_engine(settings)
|
engine = get_engine(settings)
|
||||||
|
|||||||
+6
-1
@@ -1,9 +1,12 @@
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Settings:
|
class Settings:
|
||||||
db_path: Path = field(default_factory=lambda: Path.home() / ".local/share/hqt/hqt.db")
|
db_path: Path = field(
|
||||||
|
default_factory=lambda: Path.home() / ".local/share/hqt/hqt.db"
|
||||||
|
)
|
||||||
config_dir: Path = field(default_factory=lambda: Path.home() / ".config/hqt")
|
config_dir: Path = field(default_factory=lambda: Path.home() / ".config/hqt")
|
||||||
skills_dir: Path = field(default_factory=lambda: Path.home() / ".config/hqt/skills")
|
skills_dir: Path = field(default_factory=lambda: Path.home() / ".config/hqt/skills")
|
||||||
tmux_path: str = "tmux"
|
tmux_path: str = "tmux"
|
||||||
@@ -17,8 +20,10 @@ class Settings:
|
|||||||
# truncated session name (the trailing "-" there is tmux's last-window flag).
|
# truncated session name (the trailing "-" there is tmux's last-window flag).
|
||||||
tui_window_name: str = "⌂ HQT"
|
tui_window_name: str = "⌂ HQT"
|
||||||
|
|
||||||
|
|
||||||
_settings: Settings | None = None
|
_settings: Settings | None = None
|
||||||
|
|
||||||
|
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
global _settings
|
global _settings
|
||||||
if _settings is None:
|
if _settings is None:
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
from hqt.config import Settings
|
from hqt.config import Settings
|
||||||
from hqt.db.models import Base
|
from hqt.db.models import Base
|
||||||
|
|
||||||
|
|
||||||
def get_engine(settings: Settings) -> Engine:
|
def get_engine(settings: Settings) -> Engine:
|
||||||
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
|
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
return create_engine(f"sqlite:///{settings.db_path}")
|
return create_engine(f"sqlite:///{settings.db_path}")
|
||||||
|
|
||||||
|
|
||||||
def get_session_factory(engine: Engine) -> sessionmaker:
|
def get_session_factory(engine: Engine) -> sessionmaker:
|
||||||
return sessionmaker(bind=engine)
|
return sessionmaker(bind=engine)
|
||||||
|
|
||||||
|
|
||||||
def ensure_db(settings: Settings) -> None:
|
def ensure_db(settings: Settings) -> None:
|
||||||
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
|
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
engine = get_engine(settings)
|
engine = get_engine(settings)
|
||||||
|
|||||||
+10
-1
@@ -2,9 +2,11 @@ from datetime import datetime
|
|||||||
from sqlalchemy import ForeignKey, String, func
|
from sqlalchemy import ForeignKey, String, func
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Project(Base):
|
class Project(Base):
|
||||||
__tablename__ = "projects"
|
__tablename__ = "projects"
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
@@ -15,6 +17,7 @@ class Project(Base):
|
|||||||
archived: Mapped[bool] = mapped_column(default=False)
|
archived: Mapped[bool] = mapped_column(default=False)
|
||||||
sessions: Mapped[list["Session"]] = relationship(back_populates="project")
|
sessions: Mapped[list["Session"]] = relationship(back_populates="project")
|
||||||
|
|
||||||
|
|
||||||
class Harness(Base):
|
class Harness(Base):
|
||||||
__tablename__ = "harnesses"
|
__tablename__ = "harnesses"
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
@@ -25,6 +28,7 @@ class Harness(Base):
|
|||||||
profile_json: Mapped[str | None] = mapped_column(default=None)
|
profile_json: Mapped[str | None] = mapped_column(default=None)
|
||||||
sessions: Mapped[list["Session"]] = relationship(back_populates="harness")
|
sessions: Mapped[list["Session"]] = relationship(back_populates="harness")
|
||||||
|
|
||||||
|
|
||||||
class Session(Base):
|
class Session(Base):
|
||||||
__tablename__ = "sessions"
|
__tablename__ = "sessions"
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
@@ -40,6 +44,7 @@ class Session(Base):
|
|||||||
project: Mapped["Project"] = relationship(back_populates="sessions")
|
project: Mapped["Project"] = relationship(back_populates="sessions")
|
||||||
harness: Mapped["Harness"] = relationship(back_populates="sessions")
|
harness: Mapped["Harness"] = relationship(back_populates="sessions")
|
||||||
|
|
||||||
|
|
||||||
class McpServer(Base):
|
class McpServer(Base):
|
||||||
__tablename__ = "mcp_servers"
|
__tablename__ = "mcp_servers"
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
@@ -51,10 +56,14 @@ class McpServer(Base):
|
|||||||
env_json: Mapped[str | None] = mapped_column(default=None)
|
env_json: Mapped[str | None] = mapped_column(default=None)
|
||||||
headers_json: Mapped[str | None] = mapped_column(default=None)
|
headers_json: Mapped[str | None] = mapped_column(default=None)
|
||||||
|
|
||||||
|
|
||||||
class ProjectMcpServer(Base):
|
class ProjectMcpServer(Base):
|
||||||
__tablename__ = "project_mcp_servers"
|
__tablename__ = "project_mcp_servers"
|
||||||
project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"), primary_key=True)
|
project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"), primary_key=True)
|
||||||
mcp_server_id: Mapped[int] = mapped_column(ForeignKey("mcp_servers.id"), primary_key=True)
|
mcp_server_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("mcp_servers.id"), primary_key=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProjectSkill(Base):
|
class ProjectSkill(Base):
|
||||||
__tablename__ = "project_skills"
|
__tablename__ = "project_skills"
|
||||||
|
|||||||
@@ -19,10 +19,14 @@ class HarnessConfigurator(ABC):
|
|||||||
def generate_session_id(self, hqt_session_id: int) -> str: ...
|
def generate_session_id(self, hqt_session_id: int) -> str: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def build_spawn_config(self, project_path: Path, harness_session_id: str, model: str | None = None) -> SpawnConfig: ...
|
def build_spawn_config(
|
||||||
|
self, project_path: Path, harness_session_id: str, model: str | None = None
|
||||||
|
) -> SpawnConfig: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def build_resume_config(self, project_path: Path, harness_session_id: str, model: str | None = None) -> SpawnConfig: ...
|
def build_resume_config(
|
||||||
|
self, project_path: Path, harness_session_id: str, model: str | None = None
|
||||||
|
) -> SpawnConfig: ...
|
||||||
|
|
||||||
def capture_session_id(self, project_path: Path, since: float) -> str | None:
|
def capture_session_id(self, project_path: Path, since: float) -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ class ClaudeConfigurator(HarnessConfigurator):
|
|||||||
|
|
||||||
# Markers for parse_status — conservative, case-insensitive substring checks.
|
# Markers for parse_status — conservative, case-insensitive substring checks.
|
||||||
# None returned when none match; caller falls back to activity-based status.
|
# None returned when none match; caller falls back to activity-based status.
|
||||||
_WORKING_MARKERS = (
|
_WORKING_MARKERS = ("esc to interrupt",)
|
||||||
"esc to interrupt",
|
|
||||||
)
|
|
||||||
_WAITING_MARKERS = (
|
_WAITING_MARKERS = (
|
||||||
"do you want", # permission / confirmation prompts ("Do you want to proceed?")
|
"do you want", # permission / confirmation prompts ("Do you want to proceed?")
|
||||||
"❯ 1.", # ❯ 1. — numbered choice prompt in Claude's interactive UI
|
"❯ 1.", # ❯ 1. — numbered choice prompt in Claude's interactive UI
|
||||||
@@ -23,13 +21,17 @@ class ClaudeConfigurator(HarnessConfigurator):
|
|||||||
def generate_session_id(self, hqt_session_id: int) -> str:
|
def generate_session_id(self, hqt_session_id: int) -> str:
|
||||||
return str(uuid.uuid5(NAMESPACE, str(hqt_session_id)))
|
return str(uuid.uuid5(NAMESPACE, str(hqt_session_id)))
|
||||||
|
|
||||||
def build_spawn_config(self, project_path: Path, harness_session_id: str, model: str | None = None) -> SpawnConfig:
|
def build_spawn_config(
|
||||||
|
self, project_path: Path, harness_session_id: str, model: str | None = None
|
||||||
|
) -> SpawnConfig:
|
||||||
cmd = ["claude", "--session-id", harness_session_id]
|
cmd = ["claude", "--session-id", harness_session_id]
|
||||||
if model:
|
if model:
|
||||||
cmd += ["--model", model]
|
cmd += ["--model", model]
|
||||||
return SpawnConfig(command=cmd, cwd=project_path)
|
return SpawnConfig(command=cmd, cwd=project_path)
|
||||||
|
|
||||||
def build_resume_config(self, project_path: Path, harness_session_id: str, model: str | None = None) -> SpawnConfig:
|
def build_resume_config(
|
||||||
|
self, project_path: Path, harness_session_id: str, model: str | None = None
|
||||||
|
) -> SpawnConfig:
|
||||||
cmd = ["claude", "--resume", harness_session_id]
|
cmd = ["claude", "--resume", harness_session_id]
|
||||||
if model:
|
if model:
|
||||||
cmd += ["--model", model]
|
cmd += ["--model", model]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
|
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
|
||||||
@@ -10,20 +11,22 @@ class CodexConfigurator(HarnessConfigurator):
|
|||||||
captures_session_id = True
|
captures_session_id = True
|
||||||
|
|
||||||
# Markers for parse_status — conservative, case-insensitive substring checks.
|
# Markers for parse_status — conservative, case-insensitive substring checks.
|
||||||
_WORKING_MARKERS = (
|
_WORKING_MARKERS = ("esc to interrupt",)
|
||||||
"esc to interrupt",
|
|
||||||
)
|
|
||||||
|
|
||||||
def generate_session_id(self, hqt_session_id: int) -> str:
|
def generate_session_id(self, hqt_session_id: int) -> str:
|
||||||
return str(hqt_session_id)
|
return str(hqt_session_id)
|
||||||
|
|
||||||
def build_spawn_config(self, project_path: Path, harness_session_id: str, model: str | None = None) -> SpawnConfig:
|
def build_spawn_config(
|
||||||
|
self, project_path: Path, harness_session_id: str, model: str | None = None
|
||||||
|
) -> SpawnConfig:
|
||||||
cmd = ["codex"]
|
cmd = ["codex"]
|
||||||
if model:
|
if model:
|
||||||
cmd += ["-m", model]
|
cmd += ["-m", model]
|
||||||
return SpawnConfig(command=cmd, cwd=project_path)
|
return SpawnConfig(command=cmd, cwd=project_path)
|
||||||
|
|
||||||
def build_resume_config(self, project_path: Path, harness_session_id: str, model: str | None = None) -> SpawnConfig:
|
def build_resume_config(
|
||||||
|
self, project_path: Path, harness_session_id: str, model: str | None = None
|
||||||
|
) -> SpawnConfig:
|
||||||
cmd = ["codex", "resume", harness_session_id]
|
cmd = ["codex", "resume", harness_session_id]
|
||||||
if model:
|
if model:
|
||||||
cmd += ["-m", model]
|
cmd += ["-m", model]
|
||||||
@@ -41,26 +44,34 @@ class CodexConfigurator(HarnessConfigurator):
|
|||||||
return "working"
|
return "working"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _meta_timestamp(self, payload: dict) -> float | None:
|
||||||
|
timestamp = payload.get("timestamp")
|
||||||
|
if not isinstance(timestamp, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(timestamp.replace("Z", "+00:00")).timestamp()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
def capture_session_id(self, project_path: Path, since: float) -> str | None:
|
def capture_session_id(self, project_path: Path, since: float) -> str | None:
|
||||||
sessions_dir = Path.home() / ".codex" / "sessions"
|
sessions_dir = Path.home() / ".codex" / "sessions"
|
||||||
if not sessions_dir.exists():
|
if not sessions_dir.exists():
|
||||||
return None
|
return None
|
||||||
candidates: list[tuple[float, Path]] = []
|
candidates: list[tuple[float, str]] = []
|
||||||
for rollout in sessions_dir.rglob("rollout-*.jsonl"):
|
for rollout in sessions_dir.rglob("rollout-*.jsonl"):
|
||||||
try:
|
try:
|
||||||
mtime = rollout.stat().st_mtime
|
mtime = rollout.stat().st_mtime
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
if mtime >= since:
|
|
||||||
candidates.append((mtime, rollout))
|
|
||||||
# Sort newest-first
|
|
||||||
candidates.sort(key=lambda t: t[0], reverse=True)
|
|
||||||
for _, rollout in candidates:
|
|
||||||
try:
|
|
||||||
with rollout.open() as f:
|
with rollout.open() as f:
|
||||||
meta = json.loads(f.readline())
|
meta = json.loads(f.readline())
|
||||||
if meta.get("type") == "session_meta" and str(Path(meta["payload"]["cwd"])) == str(project_path):
|
payload = meta["payload"]
|
||||||
return meta["payload"]["id"]
|
if meta.get("type") != "session_meta" or str(
|
||||||
except (json.JSONDecodeError, KeyError, OSError):
|
Path(payload["cwd"])
|
||||||
|
) != str(project_path):
|
||||||
continue
|
continue
|
||||||
return None
|
started_at = self._meta_timestamp(payload) or mtime
|
||||||
|
if started_at >= since:
|
||||||
|
candidates.append((started_at, payload["id"]))
|
||||||
|
except (json.JSONDecodeError, KeyError, OSError, TypeError):
|
||||||
|
continue
|
||||||
|
candidates.sort(key=lambda t: t[0])
|
||||||
|
return candidates[0][1] if candidates else None
|
||||||
|
|||||||
@@ -13,8 +13,12 @@ class GenericConfigurator(HarnessConfigurator):
|
|||||||
def generate_session_id(self, hqt_session_id: int) -> str:
|
def generate_session_id(self, hqt_session_id: int) -> str:
|
||||||
return str(hqt_session_id)
|
return str(hqt_session_id)
|
||||||
|
|
||||||
def build_spawn_config(self, project_path: Path, harness_session_id: str, model: str | None = None) -> SpawnConfig:
|
def build_spawn_config(
|
||||||
|
self, project_path: Path, harness_session_id: str, model: str | None = None
|
||||||
|
) -> SpawnConfig:
|
||||||
return SpawnConfig(command=self._command, cwd=project_path)
|
return SpawnConfig(command=self._command, cwd=project_path)
|
||||||
|
|
||||||
def build_resume_config(self, project_path: Path, harness_session_id: str, model: str | None = None) -> SpawnConfig:
|
def build_resume_config(
|
||||||
|
self, project_path: Path, harness_session_id: str, model: str | None = None
|
||||||
|
) -> SpawnConfig:
|
||||||
return SpawnConfig(command=self._command, cwd=project_path)
|
return SpawnConfig(command=self._command, cwd=project_path)
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ class KiroConfigurator(HarnessConfigurator):
|
|||||||
def generate_session_id(self, hqt_session_id: int) -> str:
|
def generate_session_id(self, hqt_session_id: int) -> str:
|
||||||
return str(hqt_session_id)
|
return str(hqt_session_id)
|
||||||
|
|
||||||
def build_spawn_config(self, project_path: Path, harness_session_id: str, model: str | None = None) -> SpawnConfig:
|
def build_spawn_config(
|
||||||
|
self, project_path: Path, harness_session_id: str, model: str | None = None
|
||||||
|
) -> SpawnConfig:
|
||||||
return SpawnConfig(command=["kiro-cli", "chat"], cwd=project_path)
|
return SpawnConfig(command=["kiro-cli", "chat"], cwd=project_path)
|
||||||
|
|
||||||
def build_resume_config(self, project_path: Path, harness_session_id: str, model: str | None = None) -> SpawnConfig:
|
def build_resume_config(
|
||||||
|
self, project_path: Path, harness_session_id: str, model: str | None = None
|
||||||
|
) -> SpawnConfig:
|
||||||
return SpawnConfig(command=["kiro-cli", "chat"], cwd=project_path)
|
return SpawnConfig(command=["kiro-cli", "chat"], cwd=project_path)
|
||||||
|
|||||||
+27
-5
@@ -9,8 +9,21 @@ class McpService:
|
|||||||
def __init__(self, db: DBSession):
|
def __init__(self, db: DBSession):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
def create(self, name: str, transport: str, command: str | None = None, args: list[str] | None = None, url: str | None = None) -> McpServer:
|
def create(
|
||||||
server = McpServer(name=name, transport=transport, command=command, args_json=json.dumps(args) if args else None, url=url)
|
self,
|
||||||
|
name: str,
|
||||||
|
transport: str,
|
||||||
|
command: str | None = None,
|
||||||
|
args: list[str] | None = None,
|
||||||
|
url: str | None = None,
|
||||||
|
) -> McpServer:
|
||||||
|
server = McpServer(
|
||||||
|
name=name,
|
||||||
|
transport=transport,
|
||||||
|
command=command,
|
||||||
|
args_json=json.dumps(args) if args else None,
|
||||||
|
url=url,
|
||||||
|
)
|
||||||
self.db.add(server)
|
self.db.add(server)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(server)
|
self.db.refresh(server)
|
||||||
@@ -29,15 +42,24 @@ class McpService:
|
|||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
def bind_to_project(self, project_id: int, mcp_server_id: int) -> None:
|
def bind_to_project(self, project_id: int, mcp_server_id: int) -> None:
|
||||||
self.db.add(ProjectMcpServer(project_id=project_id, mcp_server_id=mcp_server_id))
|
self.db.add(
|
||||||
|
ProjectMcpServer(project_id=project_id, mcp_server_id=mcp_server_id)
|
||||||
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
def unbind_from_project(self, project_id: int, mcp_server_id: int) -> None:
|
def unbind_from_project(self, project_id: int, mcp_server_id: int) -> None:
|
||||||
self.db.query(ProjectMcpServer).filter_by(project_id=project_id, mcp_server_id=mcp_server_id).delete()
|
self.db.query(ProjectMcpServer).filter_by(
|
||||||
|
project_id=project_id, mcp_server_id=mcp_server_id
|
||||||
|
).delete()
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
def get_project_mcps(self, project_id: int) -> list[McpServer]:
|
def get_project_mcps(self, project_id: int) -> list[McpServer]:
|
||||||
ids = [r.mcp_server_id for r in self.db.query(ProjectMcpServer).filter_by(project_id=project_id).all()]
|
ids = [
|
||||||
|
r.mcp_server_id
|
||||||
|
for r in self.db.query(ProjectMcpServer)
|
||||||
|
.filter_by(project_id=project_id)
|
||||||
|
.all()
|
||||||
|
]
|
||||||
if not ids:
|
if not ids:
|
||||||
return []
|
return []
|
||||||
return list(self.db.query(McpServer).filter(McpServer.id.in_(ids)).all())
|
return list(self.db.query(McpServer).filter(McpServer.id.in_(ids)).all())
|
||||||
|
|||||||
+111
-22
@@ -1,13 +1,15 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy.orm import Session as DBSession
|
from sqlalchemy.orm import Session as DBSession
|
||||||
|
|
||||||
from hqt.db.models import Harness, Project, Session
|
from hqt.db.models import Harness, Project, Session
|
||||||
from hqt.harnesses.base import HarnessConfigurator
|
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
|
||||||
from hqt.status import window_label
|
from hqt.status import window_label
|
||||||
from hqt.tmux.manager import SpawnRequest, TmuxManager
|
from hqt.tmux.manager import SpawnRequest, TmuxManager
|
||||||
from hqt.tmux.runner import WindowInfo
|
from hqt.tmux.runner import WindowInfo
|
||||||
@@ -40,11 +42,22 @@ class CreateSessionResult:
|
|||||||
|
|
||||||
|
|
||||||
class SessionService:
|
class SessionService:
|
||||||
def __init__(self, db: DBSession, tmux: TmuxManager, harnesses: dict[str, HarnessConfigurator]):
|
def __init__(
|
||||||
|
self,
|
||||||
|
db: DBSession,
|
||||||
|
tmux: TmuxManager,
|
||||||
|
harnesses: Mapping[str, HarnessConfigurator],
|
||||||
|
):
|
||||||
self.db = db
|
self.db = db
|
||||||
self.tmux = tmux
|
self.tmux = tmux
|
||||||
self.harnesses = harnesses
|
self.harnesses = harnesses
|
||||||
|
|
||||||
|
def _project_path(self, project_id: int) -> Path:
|
||||||
|
project = self.db.get(Project, project_id)
|
||||||
|
if project is None:
|
||||||
|
raise ValueError(f"Unknown project: {project_id}")
|
||||||
|
return Path(project.path)
|
||||||
|
|
||||||
async def _capture_session_id_with_retry(
|
async def _capture_session_id_with_retry(
|
||||||
self,
|
self,
|
||||||
configurator: HarnessConfigurator,
|
configurator: HarnessConfigurator,
|
||||||
@@ -72,6 +85,30 @@ class SessionService:
|
|||||||
)
|
)
|
||||||
return captured_id
|
return captured_id
|
||||||
|
|
||||||
|
def _session_created_epoch(self, sess: Session) -> float:
|
||||||
|
created_at = sess.created_at
|
||||||
|
if isinstance(created_at, datetime):
|
||||||
|
if created_at.tzinfo is None:
|
||||||
|
created_at = created_at.replace(tzinfo=UTC)
|
||||||
|
return created_at.timestamp()
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def _maybe_capture_missing_session_id(self, sess: Session) -> None:
|
||||||
|
configurator = self.harnesses.get(sess.harness.name)
|
||||||
|
if configurator is None or not configurator.captures_session_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
placeholder_id = configurator.generate_session_id(sess.id)
|
||||||
|
if sess.harness_session_id and sess.harness_session_id != placeholder_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
captured_id = configurator.capture_session_id(
|
||||||
|
self._project_path(sess.project_id), self._session_created_epoch(sess)
|
||||||
|
)
|
||||||
|
if captured_id and captured_id != sess.harness_session_id:
|
||||||
|
sess.harness_session_id = captured_id
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
async def create_session(
|
async def create_session(
|
||||||
self,
|
self,
|
||||||
project_id: int,
|
project_id: int,
|
||||||
@@ -90,18 +127,38 @@ class SessionService:
|
|||||||
log.error("Harness not in DB: %s", harness_name)
|
log.error("Harness not in DB: %s", harness_name)
|
||||||
raise ValueError(f"Unknown harness: {harness_name}")
|
raise ValueError(f"Unknown harness: {harness_name}")
|
||||||
configurator = self.harnesses[harness_name]
|
configurator = self.harnesses[harness_name]
|
||||||
sess = Session(project_id=project_id, harness_id=harness_row.id, nickname=nickname, model=model, tmux_session_name="placeholder", archived=False)
|
sess = Session(
|
||||||
|
project_id=project_id,
|
||||||
|
harness_id=harness_row.id,
|
||||||
|
nickname=nickname,
|
||||||
|
model=model,
|
||||||
|
tmux_session_name="placeholder",
|
||||||
|
archived=False,
|
||||||
|
)
|
||||||
self.db.add(sess)
|
self.db.add(sess)
|
||||||
self.db.flush()
|
self.db.flush()
|
||||||
sess.tmux_session_name = f"hqt-{sess.id}"
|
sess.tmux_session_name = f"hqt-{sess.id}"
|
||||||
sess.harness_session_id = configurator.generate_session_id(sess.id)
|
project_path = self._project_path(project_id)
|
||||||
project = self.db.get(Project, project_id)
|
harness_session_id = configurator.generate_session_id(sess.id)
|
||||||
spawn_cfg = configurator.build_spawn_config(Path(project.path), sess.harness_session_id, model)
|
sess.harness_session_id = harness_session_id
|
||||||
log.info("Spawning window %s: cmd=%s cwd=%s", sess.tmux_session_name, spawn_cfg.command, spawn_cfg.cwd)
|
spawn_cfg = configurator.build_spawn_config(
|
||||||
|
project_path, harness_session_id, model
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
"Spawning window %s: cmd=%s cwd=%s",
|
||||||
|
sess.tmux_session_name,
|
||||||
|
spawn_cfg.command,
|
||||||
|
spawn_cfg.cwd,
|
||||||
|
)
|
||||||
since = time.time()
|
since = time.time()
|
||||||
spawn_result = await self.tmux.spawn(SpawnRequest(
|
spawn_result = await self.tmux.spawn(
|
||||||
window_name=sess.tmux_session_name, command=spawn_cfg.command, cwd=str(spawn_cfg.cwd), env=spawn_cfg.env,
|
SpawnRequest(
|
||||||
))
|
window_name=sess.tmux_session_name,
|
||||||
|
command=spawn_cfg.command,
|
||||||
|
cwd=str(spawn_cfg.cwd),
|
||||||
|
env=spawn_cfg.env,
|
||||||
|
)
|
||||||
|
)
|
||||||
if not spawn_result.ok:
|
if not spawn_result.ok:
|
||||||
log.error(
|
log.error(
|
||||||
"Failed to spawn window %s: %s",
|
"Failed to spawn window %s: %s",
|
||||||
@@ -112,7 +169,7 @@ class SessionService:
|
|||||||
# could inadvertently capture an unrelated session running in the same cwd.
|
# could inadvertently capture an unrelated session running in the same cwd.
|
||||||
if spawn_result.ok and configurator.captures_session_id:
|
if spawn_result.ok and configurator.captures_session_id:
|
||||||
captured_id = await self._capture_session_id_with_retry(
|
captured_id = await self._capture_session_id_with_retry(
|
||||||
configurator, Path(project.path), since, sess.tmux_session_name
|
configurator, project_path, since, sess.tmux_session_name
|
||||||
)
|
)
|
||||||
if captured_id:
|
if captured_id:
|
||||||
sess.harness_session_id = captured_id
|
sess.harness_session_id = captured_id
|
||||||
@@ -127,7 +184,10 @@ class SessionService:
|
|||||||
async def attach_session(self, session_id: int) -> bool:
|
async def attach_session(self, session_id: int) -> bool:
|
||||||
"""Attach to a session. Handles all states: alive, dead pane, gone."""
|
"""Attach to a session. Handles all states: alive, dead pane, gone."""
|
||||||
sess = self.db.get(Session, session_id)
|
sess = self.db.get(Session, session_id)
|
||||||
|
if sess is None:
|
||||||
|
return False
|
||||||
window_name = sess.tmux_session_name
|
window_name = sess.tmux_session_name
|
||||||
|
self._maybe_capture_missing_session_id(sess)
|
||||||
|
|
||||||
if await self.tmux.is_alive(window_name):
|
if await self.tmux.is_alive(window_name):
|
||||||
# Window exists, process running — just switch
|
# Window exists, process running — just switch
|
||||||
@@ -151,7 +211,9 @@ class SessionService:
|
|||||||
"""
|
"""
|
||||||
# Rung 1: resume (restore prior conversation)
|
# Rung 1: resume (restore prior conversation)
|
||||||
resume_cfg = self._get_resume_config(sess)
|
resume_cfg = self._get_resume_config(sess)
|
||||||
result = await self.tmux.respawn_verified(window_name, resume_cfg.command, str(resume_cfg.cwd), env=resume_cfg.env)
|
result = await self.tmux.respawn_verified(
|
||||||
|
window_name, resume_cfg.command, str(resume_cfg.cwd), env=resume_cfg.env
|
||||||
|
)
|
||||||
if result.ok:
|
if result.ok:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -163,10 +225,17 @@ class SessionService:
|
|||||||
|
|
||||||
# Rung 2: fresh spawn (codex ignores the old session id; start fresh)
|
# Rung 2: fresh spawn (codex ignores the old session id; start fresh)
|
||||||
configurator = self.harnesses[sess.harness.name]
|
configurator = self.harnesses[sess.harness.name]
|
||||||
project = self.db.get(Project, sess.project_id)
|
project_path = self._project_path(sess.project_id)
|
||||||
spawn_cfg = configurator.build_spawn_config(Path(project.path), sess.harness_session_id, sess.model)
|
harness_session_id = (
|
||||||
|
sess.harness_session_id or configurator.generate_session_id(sess.id)
|
||||||
|
)
|
||||||
|
spawn_cfg = configurator.build_spawn_config(
|
||||||
|
project_path, harness_session_id, sess.model
|
||||||
|
)
|
||||||
since = time.time()
|
since = time.time()
|
||||||
result = await self.tmux.respawn_verified(window_name, spawn_cfg.command, str(spawn_cfg.cwd), env=spawn_cfg.env)
|
result = await self.tmux.respawn_verified(
|
||||||
|
window_name, spawn_cfg.command, str(spawn_cfg.cwd), env=spawn_cfg.env
|
||||||
|
)
|
||||||
if not result.ok:
|
if not result.ok:
|
||||||
log.error(
|
log.error(
|
||||||
"Fallback spawn also failed for %s: %s",
|
"Fallback spawn also failed for %s: %s",
|
||||||
@@ -179,7 +248,7 @@ class SessionService:
|
|||||||
# so that future resumes target this fresh conversation.
|
# so that future resumes target this fresh conversation.
|
||||||
if configurator.captures_session_id:
|
if configurator.captures_session_id:
|
||||||
new_id = await self._capture_session_id_with_retry(
|
new_id = await self._capture_session_id_with_retry(
|
||||||
configurator, Path(project.path), since, window_name
|
configurator, project_path, since, window_name
|
||||||
)
|
)
|
||||||
if new_id:
|
if new_id:
|
||||||
sess.harness_session_id = new_id
|
sess.harness_session_id = new_id
|
||||||
@@ -194,6 +263,8 @@ class SessionService:
|
|||||||
|
|
||||||
async def stop_session(self, session_id: int) -> None:
|
async def stop_session(self, session_id: int) -> None:
|
||||||
sess = self.db.get(Session, session_id)
|
sess = self.db.get(Session, session_id)
|
||||||
|
if sess is None:
|
||||||
|
return
|
||||||
if await self.tmux.window_exists(sess.tmux_session_name):
|
if await self.tmux.window_exists(sess.tmux_session_name):
|
||||||
await self.tmux.kill(sess.tmux_session_name)
|
await self.tmux.kill(sess.tmux_session_name)
|
||||||
|
|
||||||
@@ -209,17 +280,23 @@ class SessionService:
|
|||||||
poll (sync_window_labels), so no immediate tmux call is needed here.
|
poll (sync_window_labels), so no immediate tmux call is needed here.
|
||||||
"""
|
"""
|
||||||
sess = self.db.get(Session, session_id)
|
sess = self.db.get(Session, session_id)
|
||||||
|
if sess is None:
|
||||||
|
return
|
||||||
sess.nickname = (nickname or "").strip() or None
|
sess.nickname = (nickname or "").strip() or None
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
async def delete_session(self, session_id: int) -> None:
|
async def delete_session(self, session_id: int) -> None:
|
||||||
sess = self.db.get(Session, session_id)
|
sess = self.db.get(Session, session_id)
|
||||||
|
if sess is None:
|
||||||
|
return
|
||||||
if await self.tmux.window_exists(sess.tmux_session_name):
|
if await self.tmux.window_exists(sess.tmux_session_name):
|
||||||
await self.tmux.kill(sess.tmux_session_name)
|
await self.tmux.kill(sess.tmux_session_name)
|
||||||
self.db.delete(sess)
|
self.db.delete(sess)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
async def _status_for(self, sess: Session, wi: WindowInfo | None, now: float) -> str:
|
async def _status_for(
|
||||||
|
self, sess: Session, wi: WindowInfo | None, now: float
|
||||||
|
) -> str:
|
||||||
"""Compute a session's status string from its window info.
|
"""Compute a session's status string from its window info.
|
||||||
|
|
||||||
Dead/missing window → "dead". Alive window → the harness's parsed status
|
Dead/missing window → "dead". Alive window → the harness's parsed status
|
||||||
@@ -243,14 +320,21 @@ class SessionService:
|
|||||||
) -> list[SessionInfo]:
|
) -> list[SessionInfo]:
|
||||||
if now is None:
|
if now is None:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
sessions = self.db.query(Session).filter_by(project_id=project_id, archived=False).all()
|
sessions = (
|
||||||
|
self.db.query(Session)
|
||||||
|
.filter_by(project_id=project_id, archived=False)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
names = [s.tmux_session_name for s in sessions]
|
names = [s.tmux_session_name for s in sessions]
|
||||||
info_map = await self.tmux.poll_info(names)
|
info_map = await self.tmux.poll_info(names)
|
||||||
result: list[SessionInfo] = []
|
result: list[SessionInfo] = []
|
||||||
for s in sessions:
|
for s in sessions:
|
||||||
|
self._maybe_capture_missing_session_id(s)
|
||||||
wi = info_map.get(s.tmux_session_name)
|
wi = info_map.get(s.tmux_session_name)
|
||||||
status = await self._status_for(s, wi, now)
|
status = await self._status_for(s, wi, now)
|
||||||
result.append(SessionInfo(session=s, alive=bool(wi and wi.alive), status=status))
|
result.append(
|
||||||
|
SessionInfo(session=s, alive=bool(wi and wi.alive), status=status)
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def sync_window_labels(self, now: float | None = None) -> list[SessionInfo]:
|
async def sync_window_labels(self, now: float | None = None) -> list[SessionInfo]:
|
||||||
@@ -269,6 +353,7 @@ class SessionService:
|
|||||||
info_map = await self.tmux.poll_info(names)
|
info_map = await self.tmux.poll_info(names)
|
||||||
result: list[SessionInfo] = []
|
result: list[SessionInfo] = []
|
||||||
for s in sessions:
|
for s in sessions:
|
||||||
|
self._maybe_capture_missing_session_id(s)
|
||||||
wi = info_map.get(s.tmux_session_name)
|
wi = info_map.get(s.tmux_session_name)
|
||||||
alive = bool(wi and wi.alive)
|
alive = bool(wi and wi.alive)
|
||||||
status = await self._status_for(s, wi, now)
|
status = await self._status_for(s, wi, now)
|
||||||
@@ -279,7 +364,11 @@ class SessionService:
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _get_resume_config(self, sess: Session):
|
def _get_resume_config(self, sess: Session) -> SpawnConfig:
|
||||||
configurator = self.harnesses[sess.harness.name]
|
configurator = self.harnesses[sess.harness.name]
|
||||||
project = self.db.get(Project, sess.project_id)
|
harness_session_id = (
|
||||||
return configurator.build_resume_config(Path(project.path), sess.harness_session_id, sess.model)
|
sess.harness_session_id or configurator.generate_session_id(sess.id)
|
||||||
|
)
|
||||||
|
return configurator.build_resume_config(
|
||||||
|
self._project_path(sess.project_id), harness_session_id, sess.model
|
||||||
|
)
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ class SkillRegistry:
|
|||||||
if len(parts) < 3:
|
if len(parts) < 3:
|
||||||
continue
|
continue
|
||||||
meta = yaml.safe_load(parts[1])
|
meta = yaml.safe_load(parts[1])
|
||||||
skills.append(Skill(
|
skills.append(
|
||||||
|
Skill(
|
||||||
name=meta["name"],
|
name=meta["name"],
|
||||||
description=meta.get("description", ""),
|
description=meta.get("description", ""),
|
||||||
content=parts[2].strip(),
|
content=parts[2].strip(),
|
||||||
))
|
)
|
||||||
|
)
|
||||||
return skills
|
return skills
|
||||||
|
|||||||
+16
-4
@@ -31,13 +31,17 @@ class TmuxManager:
|
|||||||
async def spawn(self, req: SpawnRequest) -> SpawnResult:
|
async def spawn(self, req: SpawnRequest) -> SpawnResult:
|
||||||
"""Spawn a new harness window. Returns SpawnResult with ok, window_id, and error."""
|
"""Spawn a new harness window. Returns SpawnResult with ok, window_id, and error."""
|
||||||
cmd = shlex.join(req.command)
|
cmd = shlex.join(req.command)
|
||||||
window_id = await self.runner.new_window(req.window_name, req.cwd, cmd, env=req.env)
|
window_id = await self.runner.new_window(
|
||||||
|
req.window_name, req.cwd, cmd, env=req.env
|
||||||
|
)
|
||||||
if window_id is None:
|
if window_id is None:
|
||||||
return SpawnResult(ok=False, window_id=None, error="new_window failed")
|
return SpawnResult(ok=False, window_id=None, error="new_window failed")
|
||||||
|
|
||||||
alive, pane_text = await self.runner.verify_window_alive(req.window_name)
|
alive, pane_text = await self.runner.verify_window_alive(req.window_name)
|
||||||
if not alive:
|
if not alive:
|
||||||
error = pane_text.strip() or f"Window {req.window_name!r} pane died immediately"
|
error = (
|
||||||
|
pane_text.strip() or f"Window {req.window_name!r} pane died immediately"
|
||||||
|
)
|
||||||
return SpawnResult(ok=False, window_id=window_id, error=error)
|
return SpawnResult(ok=False, window_id=window_id, error=error)
|
||||||
|
|
||||||
return SpawnResult(ok=True, window_id=window_id, error="")
|
return SpawnResult(ok=True, window_id=window_id, error="")
|
||||||
@@ -63,11 +67,19 @@ class TmuxManager:
|
|||||||
# Window gone entirely — create fresh, which gives us a window_id
|
# Window gone entirely — create fresh, which gives us a window_id
|
||||||
window_id = await self.runner.new_window(window_name, cwd, cmd, env=env)
|
window_id = await self.runner.new_window(window_name, cwd, cmd, env=env)
|
||||||
if window_id is None:
|
if window_id is None:
|
||||||
return SpawnResult(ok=False, window_id=None, error=f"new_window failed for {window_name!r}")
|
return SpawnResult(
|
||||||
|
ok=False,
|
||||||
|
window_id=None,
|
||||||
|
error=f"new_window failed for {window_name!r}",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
ok = await self.runner.respawn_pane(window_name, cmd, cwd, env=env)
|
ok = await self.runner.respawn_pane(window_name, cmd, cwd, env=env)
|
||||||
if not ok:
|
if not ok:
|
||||||
return SpawnResult(ok=False, window_id=None, error=f"respawn_pane failed for {window_name!r}")
|
return SpawnResult(
|
||||||
|
ok=False,
|
||||||
|
window_id=None,
|
||||||
|
error=f"respawn_pane failed for {window_name!r}",
|
||||||
|
)
|
||||||
|
|
||||||
alive, pane_text = await self.runner.verify_window_alive(window_name)
|
alive, pane_text = await self.runner.verify_window_alive(window_name)
|
||||||
if not alive:
|
if not alive:
|
||||||
|
|||||||
+108
-30
@@ -15,6 +15,7 @@ WINDOW_STATUS_FORMAT = " #I:#{?@hqt_label,#{@hqt_label},#W}#F "
|
|||||||
# between the colored pills rather than a visible glyph.
|
# between the colored pills rather than a visible glyph.
|
||||||
WINDOW_STATUS_SEPARATOR = " "
|
WINDOW_STATUS_SEPARATOR = " "
|
||||||
|
|
||||||
|
|
||||||
def _home_window_format(window_name: str) -> str:
|
def _home_window_format(window_name: str) -> str:
|
||||||
"""Build the home/TUI window's status cell: just its first glyph (the house,
|
"""Build the home/TUI window's status cell: just its first glyph (the house,
|
||||||
"⌂") padded — no index, no flag, no "HQT" text.
|
"⌂") padded — no index, no flag, no "HQT" text.
|
||||||
@@ -26,6 +27,7 @@ def _home_window_format(window_name: str) -> str:
|
|||||||
glyph = window_name.strip()[:1] or window_name
|
glyph = window_name.strip()[:1] or window_name
|
||||||
return f" {glyph} "
|
return f" {glyph} "
|
||||||
|
|
||||||
|
|
||||||
# Catppuccin Frappé palette — mirrors the TUI's FRAPPE_THEME (see tui/app.py) so
|
# Catppuccin Frappé palette — mirrors the TUI's FRAPPE_THEME (see tui/app.py) so
|
||||||
# the tmux status bar and the TUI render the same colors. Hex strings only; the
|
# the tmux status bar and the TUI render the same colors. Hex strings only; the
|
||||||
# status bar is themed by apply_theme below.
|
# status bar is themed by apply_theme below.
|
||||||
@@ -50,13 +52,17 @@ _SESSION_THEME_OPTIONS: list[tuple[str, str]] = [
|
|||||||
("status-style", f"bg={_FRAPPE['mantle']},fg={_FRAPPE['text']}"),
|
("status-style", f"bg={_FRAPPE['mantle']},fg={_FRAPPE['text']}"),
|
||||||
("status-justify", "left"),
|
("status-justify", "left"),
|
||||||
("status-left-length", "24"),
|
("status-left-length", "24"),
|
||||||
("status-left",
|
(
|
||||||
|
"status-left",
|
||||||
f"#[fg={_FRAPPE['crust']},bg={_FRAPPE['blue']},bold] #S "
|
f"#[fg={_FRAPPE['crust']},bg={_FRAPPE['blue']},bold] #S "
|
||||||
f"#[fg={_FRAPPE['blue']},bg={_FRAPPE['mantle']},nobold] "),
|
f"#[fg={_FRAPPE['blue']},bg={_FRAPPE['mantle']},nobold] ",
|
||||||
|
),
|
||||||
("status-right-length", "40"),
|
("status-right-length", "40"),
|
||||||
("status-right",
|
(
|
||||||
|
"status-right",
|
||||||
f"#[fg={_FRAPPE['overlay1']},bg={_FRAPPE['mantle']}] %H:%M "
|
f"#[fg={_FRAPPE['overlay1']},bg={_FRAPPE['mantle']}] %H:%M "
|
||||||
f"#[fg={_FRAPPE['crust']},bg={_FRAPPE['blue']},bold] %d %b "),
|
f"#[fg={_FRAPPE['crust']},bg={_FRAPPE['blue']},bold] %d %b ",
|
||||||
|
),
|
||||||
("message-style", f"fg={_FRAPPE['text']},bg={_FRAPPE['surface0']}"),
|
("message-style", f"fg={_FRAPPE['text']},bg={_FRAPPE['surface0']}"),
|
||||||
("message-command-style", f"fg={_FRAPPE['text']},bg={_FRAPPE['surface0']}"),
|
("message-command-style", f"fg={_FRAPPE['text']},bg={_FRAPPE['surface0']}"),
|
||||||
]
|
]
|
||||||
@@ -73,7 +79,10 @@ _WINDOW_THEME_OPTIONS: list[tuple[str, str]] = [
|
|||||||
("window-status-separator", WINDOW_STATUS_SEPARATOR),
|
("window-status-separator", WINDOW_STATUS_SEPARATOR),
|
||||||
("window-status-style", f"fg={_FRAPPE['overlay1']},bg={_FRAPPE['mantle']}"),
|
("window-status-style", f"fg={_FRAPPE['overlay1']},bg={_FRAPPE['mantle']}"),
|
||||||
("window-status-format", WINDOW_STATUS_FORMAT),
|
("window-status-format", WINDOW_STATUS_FORMAT),
|
||||||
("window-status-current-style", f"fg={_FRAPPE['crust']},bg={_FRAPPE['peach']},bold"),
|
(
|
||||||
|
"window-status-current-style",
|
||||||
|
f"fg={_FRAPPE['crust']},bg={_FRAPPE['peach']},bold",
|
||||||
|
),
|
||||||
("window-status-current-format", WINDOW_STATUS_FORMAT),
|
("window-status-current-format", WINDOW_STATUS_FORMAT),
|
||||||
("mode-style", f"fg={_FRAPPE['crust']},bg={_FRAPPE['peach']}"),
|
("mode-style", f"fg={_FRAPPE['crust']},bg={_FRAPPE['peach']}"),
|
||||||
("pane-border-style", f"fg={_FRAPPE['surface0']}"),
|
("pane-border-style", f"fg={_FRAPPE['surface0']}"),
|
||||||
@@ -99,6 +108,7 @@ def _window_theme_args(target: str) -> list[str]:
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class WindowInfo:
|
class WindowInfo:
|
||||||
"""Activity metadata for a single tmux window."""
|
"""Activity metadata for a single tmux window."""
|
||||||
|
|
||||||
alive: bool
|
alive: bool
|
||||||
last_activity: int
|
last_activity: int
|
||||||
|
|
||||||
@@ -113,12 +123,13 @@ class TmuxRunner:
|
|||||||
async def _exec(self, *args: str) -> tuple[int, str, str]:
|
async def _exec(self, *args: str) -> tuple[int, str, str]:
|
||||||
"""Run tmux command, return (returncode, stdout, stderr)."""
|
"""Run tmux command, return (returncode, stdout, stderr)."""
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
self.tmux_path, *args,
|
self.tmux_path,
|
||||||
|
*args,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
stdout, stderr = await proc.communicate()
|
stdout, stderr = await proc.communicate()
|
||||||
return proc.returncode, stdout.decode(), stderr.decode()
|
return proc.returncode or 0, stdout.decode(), stderr.decode()
|
||||||
|
|
||||||
def _target(self, window_name: str) -> str:
|
def _target(self, window_name: str) -> str:
|
||||||
return f"{self.session_name}:={window_name}"
|
return f"{self.session_name}:={window_name}"
|
||||||
@@ -161,12 +172,24 @@ class TmuxRunner:
|
|||||||
if home_window:
|
if home_window:
|
||||||
target = self._target(home_window)
|
target = self._target(home_window)
|
||||||
home_fmt = _home_window_format(home_window)
|
home_fmt = _home_window_format(home_window)
|
||||||
args.extend([
|
args.extend(
|
||||||
";", "set-option", "-w", "-t", target,
|
[
|
||||||
"window-status-format", home_fmt,
|
";",
|
||||||
";", "set-option", "-w", "-t", target,
|
"set-option",
|
||||||
"window-status-current-format", home_fmt,
|
"-w",
|
||||||
])
|
"-t",
|
||||||
|
target,
|
||||||
|
"window-status-format",
|
||||||
|
home_fmt,
|
||||||
|
";",
|
||||||
|
"set-option",
|
||||||
|
"-w",
|
||||||
|
"-t",
|
||||||
|
target,
|
||||||
|
"window-status-current-format",
|
||||||
|
home_fmt,
|
||||||
|
]
|
||||||
|
)
|
||||||
rc, _, err = await self._exec(*args)
|
rc, _, err = await self._exec(*args)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
log.warning("apply_theme failed for session %s: %s", self.session_name, err)
|
log.warning("apply_theme failed for session %s: %s", self.session_name, err)
|
||||||
@@ -184,7 +207,11 @@ class TmuxRunner:
|
|||||||
session somehow has no windows.
|
session somehow has no windows.
|
||||||
"""
|
"""
|
||||||
rc, stdout, _ = await self._exec(
|
rc, stdout, _ = await self._exec(
|
||||||
"list-windows", "-t", self.session_name, "-F", "#{window_index}",
|
"list-windows",
|
||||||
|
"-t",
|
||||||
|
self.session_name,
|
||||||
|
"-F",
|
||||||
|
"#{window_index}",
|
||||||
)
|
)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
return 0
|
return 0
|
||||||
@@ -226,10 +253,15 @@ class TmuxRunner:
|
|||||||
idx = await self._next_window_index()
|
idx = await self._next_window_index()
|
||||||
rc, stdout, err = await self._exec(
|
rc, stdout, err = await self._exec(
|
||||||
"new-window",
|
"new-window",
|
||||||
"-t", f"{self.session_name}:{idx}",
|
"-t",
|
||||||
"-n", window_name,
|
f"{self.session_name}:{idx}",
|
||||||
"-c", cwd,
|
"-n",
|
||||||
"-P", "-F", "#{window_id}",
|
window_name,
|
||||||
|
"-c",
|
||||||
|
cwd,
|
||||||
|
"-P",
|
||||||
|
"-F",
|
||||||
|
"#{window_id}",
|
||||||
)
|
)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
log.error("new-window failed: %s", err)
|
log.error("new-window failed: %s", err)
|
||||||
@@ -245,10 +277,25 @@ class TmuxRunner:
|
|||||||
# would render with tmux's default (no Frappé current-window highlight).
|
# would render with tmux's default (no Frappé current-window highlight).
|
||||||
target = self._target(window_name)
|
target = self._target(window_name)
|
||||||
rc, _, err = await self._exec(
|
rc, _, err = await self._exec(
|
||||||
"set-option", "-t", target, "remain-on-exit", "on",
|
"set-option",
|
||||||
";", "set-option", "-t", target, "allow-rename", "off",
|
"-t",
|
||||||
";", "set-option", "-t", target, "automatic-rename", "off",
|
target,
|
||||||
";", *_window_theme_args(target),
|
"remain-on-exit",
|
||||||
|
"on",
|
||||||
|
";",
|
||||||
|
"set-option",
|
||||||
|
"-t",
|
||||||
|
target,
|
||||||
|
"allow-rename",
|
||||||
|
"off",
|
||||||
|
";",
|
||||||
|
"set-option",
|
||||||
|
"-t",
|
||||||
|
target,
|
||||||
|
"automatic-rename",
|
||||||
|
"off",
|
||||||
|
";",
|
||||||
|
*_window_theme_args(target),
|
||||||
)
|
)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
log.error("set-option (window options) failed for %s: %s", window_name, err)
|
log.error("set-option (window options) failed for %s: %s", window_name, err)
|
||||||
@@ -268,8 +315,13 @@ class TmuxRunner:
|
|||||||
async def has_window(self, window_name: str) -> bool:
|
async def has_window(self, window_name: str) -> bool:
|
||||||
"""Check if a window with this name exists."""
|
"""Check if a window with this name exists."""
|
||||||
rc, stdout, _ = await self._exec(
|
rc, stdout, _ = await self._exec(
|
||||||
"list-windows", "-t", self.session_name, "-F", "#{window_name}",
|
"list-windows",
|
||||||
"-f", f"#{{==:#{{window_name}},{window_name}}}",
|
"-t",
|
||||||
|
self.session_name,
|
||||||
|
"-F",
|
||||||
|
"#{window_name}",
|
||||||
|
"-f",
|
||||||
|
f"#{{==:#{{window_name}},{window_name}}}",
|
||||||
)
|
)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
return False
|
return False
|
||||||
@@ -278,7 +330,11 @@ class TmuxRunner:
|
|||||||
async def is_pane_dead(self, window_name: str) -> bool:
|
async def is_pane_dead(self, window_name: str) -> bool:
|
||||||
"""Check if the pane in this window has exited (remain-on-exit keeps it visible)."""
|
"""Check if the pane in this window has exited (remain-on-exit keeps it visible)."""
|
||||||
rc, stdout, _ = await self._exec(
|
rc, stdout, _ = await self._exec(
|
||||||
"list-panes", "-t", self._target(window_name), "-F", "#{pane_dead}",
|
"list-panes",
|
||||||
|
"-t",
|
||||||
|
self._target(window_name),
|
||||||
|
"-F",
|
||||||
|
"#{pane_dead}",
|
||||||
)
|
)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
return True # Window doesn't exist, treat as dead
|
return True # Window doesn't exist, treat as dead
|
||||||
@@ -320,7 +376,12 @@ class TmuxRunner:
|
|||||||
env, if provided, is passed as -e KEY=VALUE flags. Requires tmux ≥3.0.
|
env, if provided, is passed as -e KEY=VALUE flags. Requires tmux ≥3.0.
|
||||||
"""
|
"""
|
||||||
rc, _, err = await self._exec(
|
rc, _, err = await self._exec(
|
||||||
"respawn-pane", "-t", self._target(window_name), "-k", "-c", cwd,
|
"respawn-pane",
|
||||||
|
"-t",
|
||||||
|
self._target(window_name),
|
||||||
|
"-k",
|
||||||
|
"-c",
|
||||||
|
cwd,
|
||||||
*self._env_flags(env),
|
*self._env_flags(env),
|
||||||
command,
|
command,
|
||||||
)
|
)
|
||||||
@@ -347,7 +408,12 @@ class TmuxRunner:
|
|||||||
"""
|
"""
|
||||||
target = self._target(window_name)
|
target = self._target(window_name)
|
||||||
await self._exec(
|
await self._exec(
|
||||||
"set-option", "-w", "-t", target, "@hqt_label", label,
|
"set-option",
|
||||||
|
"-w",
|
||||||
|
"-t",
|
||||||
|
target,
|
||||||
|
"@hqt_label",
|
||||||
|
label,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def select_window(self, window_name: str) -> bool:
|
async def select_window(self, window_name: str) -> bool:
|
||||||
@@ -363,7 +429,11 @@ class TmuxRunner:
|
|||||||
async def list_windows(self) -> list[str]:
|
async def list_windows(self) -> list[str]:
|
||||||
"""List all window names in the session."""
|
"""List all window names in the session."""
|
||||||
rc, stdout, _ = await self._exec(
|
rc, stdout, _ = await self._exec(
|
||||||
"list-windows", "-t", self.session_name, "-F", "#{window_name}",
|
"list-windows",
|
||||||
|
"-t",
|
||||||
|
self.session_name,
|
||||||
|
"-F",
|
||||||
|
"#{window_name}",
|
||||||
)
|
)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
return []
|
return []
|
||||||
@@ -379,7 +449,11 @@ class TmuxRunner:
|
|||||||
Malformed lines (not exactly 3 columns) are skipped silently.
|
Malformed lines (not exactly 3 columns) are skipped silently.
|
||||||
"""
|
"""
|
||||||
rc, stdout, _ = await self._exec(
|
rc, stdout, _ = await self._exec(
|
||||||
"list-panes", "-s", "-t", self.session_name, "-F",
|
"list-panes",
|
||||||
|
"-s",
|
||||||
|
"-t",
|
||||||
|
self.session_name,
|
||||||
|
"-F",
|
||||||
"#{window_name}\t#{pane_dead}\t#{window_activity}",
|
"#{window_name}\t#{pane_dead}\t#{window_activity}",
|
||||||
)
|
)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
@@ -418,6 +492,10 @@ class TmuxRunner:
|
|||||||
|
|
||||||
async def capture_pane(self, window_name: str) -> str:
|
async def capture_pane(self, window_name: str) -> str:
|
||||||
_, stdout, _ = await self._exec(
|
_, stdout, _ = await self._exec(
|
||||||
"capture-pane", "-t", self._target(window_name), "-p", "-J",
|
"capture-pane",
|
||||||
|
"-t",
|
||||||
|
self._target(window_name),
|
||||||
|
"-p",
|
||||||
|
"-J",
|
||||||
)
|
)
|
||||||
return stdout
|
return stdout
|
||||||
|
|||||||
+45
-18
@@ -7,8 +7,6 @@ from textual.theme import Theme
|
|||||||
from textual.widgets import Footer
|
from textual.widgets import Footer
|
||||||
|
|
||||||
from hqt.config import get_settings
|
from hqt.config import get_settings
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
from hqt.db.engine import ensure_db, get_engine, get_session_factory
|
from hqt.db.engine import ensure_db, get_engine, get_session_factory
|
||||||
from hqt.harnesses.registry import discover_harnesses, ensure_harnesses_in_db
|
from hqt.harnesses.registry import discover_harnesses, ensure_harnesses_in_db
|
||||||
from hqt.projects.service import ProjectService
|
from hqt.projects.service import ProjectService
|
||||||
@@ -21,6 +19,8 @@ from hqt.tui.screens.rename_session import RenameSessionScreen
|
|||||||
from hqt.tui.widgets.project_list import ProjectList, ProjectSelected
|
from hqt.tui.widgets.project_list import ProjectList, ProjectSelected
|
||||||
from hqt.tui.widgets.session_list import SessionList, SessionSelected
|
from hqt.tui.widgets.session_list import SessionList, SessionSelected
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Catppuccin Frappé, mirroring the structure of Textual's built-in
|
# Catppuccin Frappé, mirroring the structure of Textual's built-in
|
||||||
# catppuccin-mocha theme so no colors are auto-derived off-palette.
|
# catppuccin-mocha theme so no colors are auto-derived off-palette.
|
||||||
@@ -82,6 +82,14 @@ class HqtApp(App):
|
|||||||
self._session_service: SessionService | None = None
|
self._session_service: SessionService | None = None
|
||||||
self._project_service: ProjectService | None = None
|
self._project_service: ProjectService | None = None
|
||||||
|
|
||||||
|
def _projects(self) -> ProjectService:
|
||||||
|
assert self._project_service is not None
|
||||||
|
return self._project_service
|
||||||
|
|
||||||
|
def _sessions(self) -> SessionService:
|
||||||
|
assert self._session_service is not None
|
||||||
|
return self._session_service
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
# No Header: the project and session panels share the top row directly
|
# No Header: the project and session panels share the top row directly
|
||||||
# (ProjectList docks left, SessionList fills the rest), with the keybind
|
# (ProjectList docks left, SessionList fills the rest), with the keybind
|
||||||
@@ -114,15 +122,17 @@ class HqtApp(App):
|
|||||||
log.info("TUI mounted successfully")
|
log.info("TUI mounted successfully")
|
||||||
|
|
||||||
def _load_projects(self) -> None:
|
def _load_projects(self) -> None:
|
||||||
projects = self._project_service.list_all()
|
projects = self._projects().list_all()
|
||||||
|
|
||||||
async def _do() -> None:
|
async def _do() -> None:
|
||||||
await self.query_one(ProjectList).refresh_projects(projects)
|
await self.query_one(ProjectList).refresh_projects(projects)
|
||||||
|
|
||||||
self.run_worker(_do())
|
self.run_worker(_do())
|
||||||
|
|
||||||
async def _poll_sessions(self) -> None:
|
async def _poll_sessions(self) -> None:
|
||||||
# Sync every session's tmux window label (session-wide), then update the
|
# Sync every session's tmux window label (session-wide), then update the
|
||||||
# UI for the selected project from the same computed status — no recompute.
|
# UI for the selected project from the same computed status — no recompute.
|
||||||
infos = await self._session_service.sync_window_labels()
|
infos = await self._sessions().sync_window_labels()
|
||||||
if self._selected_project_id is not None:
|
if self._selected_project_id is not None:
|
||||||
subset = [
|
subset = [
|
||||||
i for i in infos if i.session.project_id == self._selected_project_id
|
i for i in infos if i.session.project_id == self._selected_project_id
|
||||||
@@ -132,12 +142,14 @@ class HqtApp(App):
|
|||||||
async def _refresh_sessions(self, restore_selection: bool = False) -> None:
|
async def _refresh_sessions(self, restore_selection: bool = False) -> None:
|
||||||
if self._selected_project_id is None:
|
if self._selected_project_id is None:
|
||||||
return
|
return
|
||||||
infos = await self._session_service.list_sessions(self._selected_project_id)
|
infos = await self._sessions().list_sessions(self._selected_project_id)
|
||||||
sl = self.query_one(SessionList)
|
sl = self.query_one(SessionList)
|
||||||
if restore_selection:
|
if restore_selection:
|
||||||
# Project switch: restore this project's remembered session, or let the
|
# Project switch: restore this project's remembered session, or let the
|
||||||
# widget default to the first session when there's no memory yet.
|
# widget default to the first session when there's no memory yet.
|
||||||
remembered = self._selected_session_by_project.get(self._selected_project_id)
|
remembered = self._selected_session_by_project.get(
|
||||||
|
self._selected_project_id
|
||||||
|
)
|
||||||
await sl.refresh_sessions(infos, select_session_id=remembered)
|
await sl.refresh_sessions(infos, select_session_id=remembered)
|
||||||
else:
|
else:
|
||||||
# Poll / post-action refresh: keep the current selection.
|
# Poll / post-action refresh: keep the current selection.
|
||||||
@@ -149,7 +161,9 @@ class HqtApp(App):
|
|||||||
|
|
||||||
def on_session_selected(self, message: SessionSelected) -> None:
|
def on_session_selected(self, message: SessionSelected) -> None:
|
||||||
if self._selected_project_id is not None:
|
if self._selected_project_id is not None:
|
||||||
self._selected_session_by_project[self._selected_project_id] = message.session_id
|
self._selected_session_by_project[self._selected_project_id] = (
|
||||||
|
message.session_id
|
||||||
|
)
|
||||||
|
|
||||||
def action_toggle_focus(self) -> None:
|
def action_toggle_focus(self) -> None:
|
||||||
self.screen.focus_next()
|
self.screen.focus_next()
|
||||||
@@ -158,7 +172,7 @@ class HqtApp(App):
|
|||||||
def on_dismiss(result: tuple[str, str] | None) -> None:
|
def on_dismiss(result: tuple[str, str] | None) -> None:
|
||||||
if result:
|
if result:
|
||||||
name, path = result
|
name, path = result
|
||||||
self._project_service.create(name, path)
|
self._projects().create(name, path)
|
||||||
self._load_projects()
|
self._load_projects()
|
||||||
|
|
||||||
self.push_screen(ProjectFormScreen(), on_dismiss)
|
self.push_screen(ProjectFormScreen(), on_dismiss)
|
||||||
@@ -168,7 +182,7 @@ class HqtApp(App):
|
|||||||
if project_id is None:
|
if project_id is None:
|
||||||
self.notify("Select a project first", severity="warning")
|
self.notify("Select a project first", severity="warning")
|
||||||
return
|
return
|
||||||
project = self._project_service.get(project_id)
|
project = self._projects().get(project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
self.notify("Project not found", severity="error")
|
self.notify("Project not found", severity="error")
|
||||||
return
|
return
|
||||||
@@ -177,7 +191,7 @@ class HqtApp(App):
|
|||||||
if result:
|
if result:
|
||||||
name, path = result
|
name, path = result
|
||||||
try:
|
try:
|
||||||
self._project_service.update(project_id, name, path)
|
self._projects().update(project_id, name, path)
|
||||||
except ValueError as err:
|
except ValueError as err:
|
||||||
self.notify(str(err), severity="error")
|
self.notify(str(err), severity="error")
|
||||||
return
|
return
|
||||||
@@ -196,6 +210,7 @@ class HqtApp(App):
|
|||||||
if self._selected_project_id is None:
|
if self._selected_project_id is None:
|
||||||
self.notify("Select a project first", severity="warning")
|
self.notify("Select a project first", severity="warning")
|
||||||
return
|
return
|
||||||
|
project_id = self._selected_project_id
|
||||||
harness_names = list(discover_harnesses().keys())
|
harness_names = list(discover_harnesses().keys())
|
||||||
if not harness_names:
|
if not harness_names:
|
||||||
self.notify("No harnesses found", severity="error")
|
self.notify("No harnesses found", severity="error")
|
||||||
@@ -204,13 +219,18 @@ class HqtApp(App):
|
|||||||
def on_dismiss(result: tuple[str, str, str | None] | None) -> None:
|
def on_dismiss(result: tuple[str, str, str | None] | None) -> None:
|
||||||
if result:
|
if result:
|
||||||
harness_name, nickname, model = result
|
harness_name, nickname, model = result
|
||||||
|
|
||||||
async def _do() -> None:
|
async def _do() -> None:
|
||||||
create_result = await self._session_service.create_session(
|
create_result = await self._sessions().create_session(
|
||||||
self._selected_project_id, harness_name, nickname, model
|
project_id, harness_name, nickname, model
|
||||||
)
|
)
|
||||||
if not create_result.spawn_ok:
|
if not create_result.spawn_ok:
|
||||||
first_line = next(
|
first_line = next(
|
||||||
(ln for ln in create_result.spawn_error.splitlines() if ln.strip()),
|
(
|
||||||
|
ln
|
||||||
|
for ln in create_result.spawn_error.splitlines()
|
||||||
|
if ln.strip()
|
||||||
|
),
|
||||||
create_result.spawn_error,
|
create_result.spawn_error,
|
||||||
)[:120]
|
)[:120]
|
||||||
self.notify(
|
self.notify(
|
||||||
@@ -218,6 +238,7 @@ class HqtApp(App):
|
|||||||
severity="error",
|
severity="error",
|
||||||
)
|
)
|
||||||
await self._refresh_sessions()
|
await self._refresh_sessions()
|
||||||
|
|
||||||
self.run_worker(_do())
|
self.run_worker(_do())
|
||||||
|
|
||||||
self.push_screen(NewSessionScreen(harness_names), on_dismiss)
|
self.push_screen(NewSessionScreen(harness_names), on_dismiss)
|
||||||
@@ -236,18 +257,22 @@ class HqtApp(App):
|
|||||||
def action_attach_session(self) -> None:
|
def action_attach_session(self) -> None:
|
||||||
sid = self.query_one(SessionList).get_selected_session_id()
|
sid = self.query_one(SessionList).get_selected_session_id()
|
||||||
if sid:
|
if sid:
|
||||||
|
|
||||||
async def _do() -> None:
|
async def _do() -> None:
|
||||||
ok = await self._session_service.attach_session(sid)
|
ok = await self._sessions().attach_session(sid)
|
||||||
if not ok:
|
if not ok:
|
||||||
self.notify("Failed to attach session", severity="error")
|
self.notify("Failed to attach session", severity="error")
|
||||||
|
|
||||||
self.run_worker(_do())
|
self.run_worker(_do())
|
||||||
|
|
||||||
def action_delete_session(self) -> None:
|
def action_delete_session(self) -> None:
|
||||||
sid = self.query_one(SessionList).get_selected_session_id()
|
sid = self.query_one(SessionList).get_selected_session_id()
|
||||||
if sid:
|
if sid:
|
||||||
|
|
||||||
async def _do() -> None:
|
async def _do() -> None:
|
||||||
await self._session_service.delete_session(sid)
|
await self._sessions().delete_session(sid)
|
||||||
await self._refresh_sessions()
|
await self._refresh_sessions()
|
||||||
|
|
||||||
self.run_worker(_do())
|
self.run_worker(_do())
|
||||||
|
|
||||||
def action_rename_session(self) -> None:
|
def action_rename_session(self) -> None:
|
||||||
@@ -255,14 +280,14 @@ class HqtApp(App):
|
|||||||
if sid is None:
|
if sid is None:
|
||||||
self.notify("Select a session first", severity="warning")
|
self.notify("Select a session first", severity="warning")
|
||||||
return
|
return
|
||||||
sess = self._session_service.get_session(sid)
|
sess = self._sessions().get_session(sid)
|
||||||
if sess is None:
|
if sess is None:
|
||||||
self.notify("Session not found", severity="error")
|
self.notify("Session not found", severity="error")
|
||||||
return
|
return
|
||||||
|
|
||||||
def on_dismiss(result: str | None) -> None:
|
def on_dismiss(result: str | None) -> None:
|
||||||
if result is not None:
|
if result is not None:
|
||||||
self._session_service.rename_session(sid, result)
|
self._sessions().rename_session(sid, result)
|
||||||
self.run_worker(self._refresh_sessions())
|
self.run_worker(self._refresh_sessions())
|
||||||
|
|
||||||
self.push_screen(
|
self.push_screen(
|
||||||
@@ -273,7 +298,9 @@ class HqtApp(App):
|
|||||||
def action_stop_session(self) -> None:
|
def action_stop_session(self) -> None:
|
||||||
sid = self.query_one(SessionList).get_selected_session_id()
|
sid = self.query_one(SessionList).get_selected_session_id()
|
||||||
if sid:
|
if sid:
|
||||||
|
|
||||||
async def _do() -> None:
|
async def _do() -> None:
|
||||||
await self._session_service.stop_session(sid)
|
await self._sessions().stop_session(sid)
|
||||||
await self._refresh_sessions()
|
await self._refresh_sessions()
|
||||||
|
|
||||||
self.run_worker(_do())
|
self.run_worker(_do())
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ class ProjectList(Widget, can_focus=False):
|
|||||||
await lv.clear()
|
await lv.clear()
|
||||||
for p in projects:
|
for p in projects:
|
||||||
item = ListItem(Label(p.name), id=f"proj-{p.id}")
|
item = ListItem(Label(p.name), id=f"proj-{p.id}")
|
||||||
item.data = p.id # type: ignore[attr-defined]
|
|
||||||
lv.append(item)
|
lv.append(item)
|
||||||
# Default to the first project so one is always selected on launch; keep
|
# Default to the first project so one is always selected on launch; keep
|
||||||
# the prior position across reloads (e.g. after add/edit). Setting `index`
|
# the prior position across reloads (e.g. after add/edit). Setting `index`
|
||||||
@@ -35,11 +34,16 @@ class ProjectList(Widget, can_focus=False):
|
|||||||
lv.index = 0
|
lv.index = 0
|
||||||
|
|
||||||
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
||||||
if event.item and hasattr(event.item, "data"):
|
project_id = self._project_id_from_item(event.item)
|
||||||
self.post_message(ProjectSelected(event.item.data))
|
if project_id is not None:
|
||||||
|
self.post_message(ProjectSelected(project_id))
|
||||||
|
|
||||||
def get_selected_project_id(self) -> int | None:
|
def get_selected_project_id(self) -> int | None:
|
||||||
lv = self.query_one("#project-list", ListView)
|
lv = self.query_one("#project-list", ListView)
|
||||||
if lv.highlighted_child and hasattr(lv.highlighted_child, "data"):
|
return self._project_id_from_item(lv.highlighted_child)
|
||||||
return lv.highlighted_child.data
|
|
||||||
|
def _project_id_from_item(self, item: ListItem | None) -> int | None:
|
||||||
|
item_id = item.id if item else None
|
||||||
|
if item_id and item_id.startswith("proj-"):
|
||||||
|
return int(item_id.removeprefix("proj-"))
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class SessionSelected(Message):
|
|||||||
self.session_id = session_id
|
self.session_id = session_id
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
|
|
||||||
_STATUS_COLORS: dict[str, str] = {
|
_STATUS_COLORS: dict[str, str] = {
|
||||||
"working": "#a6d189", # Green
|
"working": "#a6d189", # Green
|
||||||
"waiting": "#e5c890", # Yellow
|
"waiting": "#e5c890", # Yellow
|
||||||
@@ -40,7 +41,9 @@ class SessionList(Widget, can_focus=False):
|
|||||||
yield Label("Sessions", id="session-header")
|
yield Label("Sessions", id="session-header")
|
||||||
yield VimListView(id="session-list")
|
yield VimListView(id="session-list")
|
||||||
|
|
||||||
async def refresh_sessions(self, session_infos: list, select_session_id=_UNSET) -> None:
|
async def refresh_sessions(
|
||||||
|
self, session_infos: list, select_session_id=_UNSET
|
||||||
|
) -> None:
|
||||||
lv = self.query_one("#session-list", ListView)
|
lv = self.query_one("#session-list", ListView)
|
||||||
# Build new items and check if anything changed
|
# Build new items and check if anything changed
|
||||||
new_items = []
|
new_items = []
|
||||||
@@ -84,15 +87,19 @@ class SessionList(Widget, can_focus=False):
|
|||||||
await lv.clear()
|
await lv.clear()
|
||||||
for item_id, text, sid in new_items:
|
for item_id, text, sid in new_items:
|
||||||
item = ListItem(Label(text), id=item_id)
|
item = ListItem(Label(text), id=item_id)
|
||||||
item.data = sid # type: ignore[attr-defined]
|
|
||||||
lv.append(item)
|
lv.append(item)
|
||||||
|
|
||||||
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
||||||
if event.item and hasattr(event.item, "data"):
|
session_id = self._session_id_from_item(event.item)
|
||||||
self.post_message(SessionSelected(event.item.data))
|
if session_id is not None:
|
||||||
|
self.post_message(SessionSelected(session_id))
|
||||||
|
|
||||||
def get_selected_session_id(self) -> int | None:
|
def get_selected_session_id(self) -> int | None:
|
||||||
lv = self.query_one("#session-list", ListView)
|
lv = self.query_one("#session-list", ListView)
|
||||||
if lv.highlighted_child and hasattr(lv.highlighted_child, "data"):
|
return self._session_id_from_item(lv.highlighted_child)
|
||||||
return lv.highlighted_child.data
|
|
||||||
|
def _session_id_from_item(self, item: ListItem | None) -> int | None:
|
||||||
|
item_id = item.id if item else None
|
||||||
|
if item_id and item_id.startswith("sess-"):
|
||||||
|
return int(item_id.removeprefix("sess-"))
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
from sqlalchemy.orm import Session as DbSession
|
|
||||||
|
|
||||||
from hqt.config import Settings
|
from hqt.config import Settings
|
||||||
from hqt.db.engine import ensure_db, get_engine, get_session_factory
|
from hqt.db.engine import ensure_db, get_engine, get_session_factory
|
||||||
|
|||||||
+56
-7
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -73,8 +74,16 @@ def test_codex_resume_uses_uuid():
|
|||||||
def test_codex_capture_session_id(tmp_path):
|
def test_codex_capture_session_id(tmp_path):
|
||||||
sessions_dir = tmp_path / ".codex" / "sessions" / "2026" / "06" / "09"
|
sessions_dir = tmp_path / ".codex" / "sessions" / "2026" / "06" / "09"
|
||||||
sessions_dir.mkdir(parents=True)
|
sessions_dir.mkdir(parents=True)
|
||||||
rollout = sessions_dir / "rollout-20260609-abcdef12-3456-7890-abcd-ef1234567890.jsonl"
|
rollout = (
|
||||||
meta = {"type": "session_meta", "payload": {"id": "abcdef12-3456-7890-abcd-ef1234567890", "cwd": "/projects/foo"}}
|
sessions_dir / "rollout-20260609-abcdef12-3456-7890-abcd-ef1234567890.jsonl"
|
||||||
|
)
|
||||||
|
meta = {
|
||||||
|
"type": "session_meta",
|
||||||
|
"payload": {
|
||||||
|
"id": "abcdef12-3456-7890-abcd-ef1234567890",
|
||||||
|
"cwd": "/projects/foo",
|
||||||
|
},
|
||||||
|
}
|
||||||
rollout.write_text(json.dumps(meta) + "\n")
|
rollout.write_text(json.dumps(meta) + "\n")
|
||||||
|
|
||||||
since = time.time() - 60 # far enough back that freshly-written file qualifies
|
since = time.time() - 60 # far enough back that freshly-written file qualifies
|
||||||
@@ -100,7 +109,9 @@ def test_codex_capture_session_id_wrong_cwd(tmp_path):
|
|||||||
|
|
||||||
@patch("hqt.harnesses.registry.shutil.which")
|
@patch("hqt.harnesses.registry.shutil.which")
|
||||||
def test_registry_discovers_harnesses(mock_which):
|
def test_registry_discovers_harnesses(mock_which):
|
||||||
mock_which.side_effect = lambda x: "/usr/bin/" + x if x in ("claude", "codex") else None
|
mock_which.side_effect = lambda x: (
|
||||||
|
"/usr/bin/" + x if x in ("claude", "codex") else None
|
||||||
|
)
|
||||||
result = discover_harnesses()
|
result = discover_harnesses()
|
||||||
assert "claude" in result
|
assert "claude" in result
|
||||||
assert "codex" in result
|
assert "codex" in result
|
||||||
@@ -120,6 +131,7 @@ def test_codex_captures_session_id_flag():
|
|||||||
def test_base_captures_session_id_flag_false():
|
def test_base_captures_session_id_flag_false():
|
||||||
"""HarnessConfigurator base class default must be False."""
|
"""HarnessConfigurator base class default must be False."""
|
||||||
from hqt.harnesses.base import HarnessConfigurator
|
from hqt.harnesses.base import HarnessConfigurator
|
||||||
|
|
||||||
assert HarnessConfigurator.captures_session_id is False
|
assert HarnessConfigurator.captures_session_id is False
|
||||||
|
|
||||||
|
|
||||||
@@ -128,8 +140,12 @@ def test_claude_captures_session_id_flag_false():
|
|||||||
assert ClaudeConfigurator.captures_session_id is False
|
assert ClaudeConfigurator.captures_session_id is False
|
||||||
|
|
||||||
|
|
||||||
def _write_rollout(path: Path, session_id: str, cwd: str) -> None:
|
def _write_rollout(
|
||||||
|
path: Path, session_id: str, cwd: str, timestamp: str | None = None
|
||||||
|
) -> None:
|
||||||
meta = {"type": "session_meta", "payload": {"id": session_id, "cwd": cwd}}
|
meta = {"type": "session_meta", "payload": {"id": session_id, "cwd": cwd}}
|
||||||
|
if timestamp:
|
||||||
|
meta["payload"]["timestamp"] = timestamp
|
||||||
path.write_text(json.dumps(meta) + "\n")
|
path.write_text(json.dumps(meta) + "\n")
|
||||||
|
|
||||||
|
|
||||||
@@ -168,8 +184,8 @@ def test_codex_capture_accepts_file_after_since(tmp_path):
|
|||||||
assert result == "new-id"
|
assert result == "new-id"
|
||||||
|
|
||||||
|
|
||||||
def test_codex_capture_newest_post_since_cwd_match_wins(tmp_path):
|
def test_codex_capture_first_post_since_cwd_match_wins(tmp_path):
|
||||||
"""Among multiple post-since rollouts, newest matching cwd wins over older matching."""
|
"""Among multiple post-since rollouts, the one started nearest since wins."""
|
||||||
sessions_dir = tmp_path / ".codex" / "sessions" / "dir"
|
sessions_dir = tmp_path / ".codex" / "sessions" / "dir"
|
||||||
sessions_dir.mkdir(parents=True)
|
sessions_dir.mkdir(parents=True)
|
||||||
|
|
||||||
@@ -186,7 +202,39 @@ def test_codex_capture_newest_post_since_cwd_match_wins(tmp_path):
|
|||||||
c = CodexConfigurator()
|
c = CodexConfigurator()
|
||||||
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
|
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
|
||||||
result = c.capture_session_id(Path("/projects/foo"), since)
|
result = c.capture_session_id(Path("/projects/foo"), since)
|
||||||
assert result == "newer-id"
|
assert result == "older-id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_codex_capture_prefers_started_nearest_since_over_newest_file(tmp_path):
|
||||||
|
"""Late capture should match the Codex session spawned at since, not a later same-cwd session."""
|
||||||
|
sessions_dir = tmp_path / ".codex" / "sessions" / "dir"
|
||||||
|
sessions_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
target = sessions_dir / "rollout-target.jsonl"
|
||||||
|
unrelated_later = sessions_dir / "rollout-later.jsonl"
|
||||||
|
|
||||||
|
since = datetime(2026, 6, 10, 21, 9, 15, tzinfo=UTC).timestamp()
|
||||||
|
_write_rollout(
|
||||||
|
target,
|
||||||
|
"target-id",
|
||||||
|
"/projects/foo",
|
||||||
|
timestamp="2026-06-10T21:09:16.000Z",
|
||||||
|
)
|
||||||
|
_write_rollout(
|
||||||
|
unrelated_later,
|
||||||
|
"later-id",
|
||||||
|
"/projects/foo",
|
||||||
|
timestamp="2026-06-10T21:45:45.000Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
os.utime(target, (now - 60, now - 60))
|
||||||
|
os.utime(unrelated_later, (now, now))
|
||||||
|
|
||||||
|
c = CodexConfigurator()
|
||||||
|
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
|
||||||
|
result = c.capture_session_id(Path("/projects/foo"), since)
|
||||||
|
assert result == "target-id"
|
||||||
|
|
||||||
|
|
||||||
def test_codex_capture_skips_non_matching_cwd_picks_older_matching(tmp_path):
|
def test_codex_capture_skips_non_matching_cwd_picks_older_matching(tmp_path):
|
||||||
@@ -273,6 +321,7 @@ CODEX_IDLE_SCREEN = """\
|
|||||||
def test_base_parse_status_returns_none():
|
def test_base_parse_status_returns_none():
|
||||||
"""Base HarnessConfigurator.parse_status always returns None."""
|
"""Base HarnessConfigurator.parse_status always returns None."""
|
||||||
from hqt.harnesses.configurators.kiro import KiroConfigurator
|
from hqt.harnesses.configurators.kiro import KiroConfigurator
|
||||||
|
|
||||||
k = KiroConfigurator()
|
k = KiroConfigurator()
|
||||||
assert k.parse_status("anything") is None
|
assert k.parse_status("anything") is None
|
||||||
assert k.parse_status("") is None
|
assert k.parse_status("") is None
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
@@ -47,14 +46,19 @@ async def test_full_lifecycle(setup):
|
|||||||
assert project.id == 1
|
assert project.id == 1
|
||||||
|
|
||||||
# Create session
|
# Create session
|
||||||
create_result = await sess_svc.create_session(project_id=project.id, harness_name="claude-code", nickname="e2e")
|
create_result = await sess_svc.create_session(
|
||||||
|
project_id=project.id, harness_name="claude-code", nickname="e2e"
|
||||||
|
)
|
||||||
session = create_result.session
|
session = create_result.session
|
||||||
assert session.id is not None
|
assert session.id is not None
|
||||||
tmux.spawn.assert_called_once()
|
tmux.spawn.assert_called_once()
|
||||||
|
|
||||||
# List sessions - alive
|
# List sessions - alive
|
||||||
from hqt.tmux.runner import WindowInfo
|
from hqt.tmux.runner import WindowInfo
|
||||||
tmux.poll_info.return_value = {session.tmux_session_name: WindowInfo(alive=True, last_activity=0)}
|
|
||||||
|
tmux.poll_info.return_value = {
|
||||||
|
session.tmux_session_name: WindowInfo(alive=True, last_activity=0)
|
||||||
|
}
|
||||||
infos = await sess_svc.list_sessions(project_id=project.id)
|
infos = await sess_svc.list_sessions(project_id=project.id)
|
||||||
assert len(infos) == 1
|
assert len(infos) == 1
|
||||||
assert infos[0].alive is True
|
assert infos[0].alive is True
|
||||||
@@ -64,7 +68,9 @@ async def test_full_lifecycle(setup):
|
|||||||
tmux.kill.assert_called_once_with(session.tmux_session_name)
|
tmux.kill.assert_called_once_with(session.tmux_session_name)
|
||||||
|
|
||||||
# List sessions - dead
|
# List sessions - dead
|
||||||
tmux.poll_info.return_value = {session.tmux_session_name: WindowInfo(alive=False, last_activity=0)}
|
tmux.poll_info.return_value = {
|
||||||
|
session.tmux_session_name: WindowInfo(alive=False, last_activity=0)
|
||||||
|
}
|
||||||
infos = await sess_svc.list_sessions(project_id=project.id)
|
infos = await sess_svc.list_sessions(project_id=project.id)
|
||||||
assert len(infos) == 1
|
assert len(infos) == 1
|
||||||
assert infos[0].alive is False
|
assert infos[0].alive is False
|
||||||
|
|||||||
+224
-60
@@ -33,7 +33,9 @@ def tmux():
|
|||||||
m.capture_pane = AsyncMock(return_value="")
|
m.capture_pane = AsyncMock(return_value="")
|
||||||
m.attach = AsyncMock()
|
m.attach = AsyncMock()
|
||||||
m.window_exists = AsyncMock(return_value=True)
|
m.window_exists = AsyncMock(return_value=True)
|
||||||
m.respawn_verified = AsyncMock(return_value=SpawnResult(ok=True, window_id=None, error=""))
|
m.respawn_verified = AsyncMock(
|
||||||
|
return_value=SpawnResult(ok=True, window_id=None, error="")
|
||||||
|
)
|
||||||
return m
|
return m
|
||||||
|
|
||||||
|
|
||||||
@@ -42,7 +44,9 @@ def harnesses():
|
|||||||
h = MagicMock()
|
h = MagicMock()
|
||||||
h.captures_session_id = False
|
h.captures_session_id = False
|
||||||
h.generate_session_id.return_value = "sess-1"
|
h.generate_session_id.return_value = "sess-1"
|
||||||
h.build_spawn_config.return_value = MagicMock(command=["claude"], env={}, cwd=Path("/tmp/myproj"))
|
h.build_spawn_config.return_value = MagicMock(
|
||||||
|
command=["claude"], env={}, cwd=Path("/tmp/myproj")
|
||||||
|
)
|
||||||
h.parse_status = MagicMock(return_value=None)
|
h.parse_status = MagicMock(return_value=None)
|
||||||
return {"claude-code": h}
|
return {"claude-code": h}
|
||||||
|
|
||||||
@@ -54,7 +58,9 @@ def service(db, tmux, harnesses):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_session(service, db, tmux, harnesses):
|
async def test_create_session(service, db, tmux, harnesses):
|
||||||
result = await service.create_session(project_id=1, harness_name="claude-code", nickname="test")
|
result = await service.create_session(
|
||||||
|
project_id=1, harness_name="claude-code", nickname="test"
|
||||||
|
)
|
||||||
sess = result.session
|
sess = result.session
|
||||||
assert sess.id is not None
|
assert sess.id is not None
|
||||||
assert sess.tmux_session_name == f"hqt-{sess.id}"
|
assert sess.tmux_session_name == f"hqt-{sess.id}"
|
||||||
@@ -66,6 +72,7 @@ async def test_create_session(service, db, tmux, harnesses):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_list_sessions(service, db, tmux):
|
async def test_list_sessions(service, db, tmux):
|
||||||
from hqt.tmux.runner import WindowInfo
|
from hqt.tmux.runner import WindowInfo
|
||||||
|
|
||||||
await service.create_session(project_id=1, harness_name="claude-code")
|
await service.create_session(project_id=1, harness_name="claude-code")
|
||||||
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1000)}
|
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1000)}
|
||||||
result = await service.list_sessions(project_id=1)
|
result = await service.list_sessions(project_id=1)
|
||||||
@@ -95,7 +102,10 @@ async def test_stop_session_no_kill_when_window_missing(service, db, tmux):
|
|||||||
async def test_create_session_logs_failed_spawn(service, db, tmux, caplog):
|
async def test_create_session_logs_failed_spawn(service, db, tmux, caplog):
|
||||||
"""create_session logs error including pane text when spawn fails, but still commits."""
|
"""create_session logs error including pane text when spawn fails, but still commits."""
|
||||||
import logging
|
import logging
|
||||||
tmux.spawn.return_value = SpawnResult(ok=False, window_id=None, error="bash: command not found")
|
|
||||||
|
tmux.spawn.return_value = SpawnResult(
|
||||||
|
ok=False, window_id=None, error="bash: command not found"
|
||||||
|
)
|
||||||
with caplog.at_level(logging.ERROR):
|
with caplog.at_level(logging.ERROR):
|
||||||
result = await service.create_session(project_id=1, harness_name="claude-code")
|
result = await service.create_session(project_id=1, harness_name="claude-code")
|
||||||
assert result.session.id is not None # row was committed
|
assert result.session.id is not None # row was committed
|
||||||
@@ -108,16 +118,21 @@ async def test_create_session_logs_failed_spawn(service, db, tmux, caplog):
|
|||||||
async def test_attach_session_passes_env(service, db, tmux, harnesses):
|
async def test_attach_session_passes_env(service, db, tmux, harnesses):
|
||||||
"""attach_session passes cfg.env to tmux.respawn_verified when the window needs respawning."""
|
"""attach_session passes cfg.env to tmux.respawn_verified when the window needs respawning."""
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
# Create a session first
|
# Create a session first
|
||||||
await service.create_session(project_id=1, harness_name="claude-code")
|
await service.create_session(project_id=1, harness_name="claude-code")
|
||||||
|
|
||||||
# Set up harness to return a config with env
|
# Set up harness to return a config with env
|
||||||
env_cfg = MagicMock(command=["claude"], env={"SESSION_VAR": "abc"}, cwd=Path("/tmp/myproj"))
|
env_cfg = MagicMock(
|
||||||
|
command=["claude"], env={"SESSION_VAR": "abc"}, cwd=Path("/tmp/myproj")
|
||||||
|
)
|
||||||
harnesses["claude-code"].build_resume_config = MagicMock(return_value=env_cfg)
|
harnesses["claude-code"].build_resume_config = MagicMock(return_value=env_cfg)
|
||||||
|
|
||||||
# Simulate window is not alive → respawn path
|
# Simulate window is not alive → respawn path
|
||||||
tmux.is_alive = AsyncMock(return_value=False)
|
tmux.is_alive = AsyncMock(return_value=False)
|
||||||
tmux.respawn_verified = AsyncMock(return_value=SpawnResult(ok=True, window_id=None, error=""))
|
tmux.respawn_verified = AsyncMock(
|
||||||
|
return_value=SpawnResult(ok=True, window_id=None, error="")
|
||||||
|
)
|
||||||
tmux.attach = AsyncMock(return_value=True)
|
tmux.attach = AsyncMock(return_value=True)
|
||||||
|
|
||||||
await service.attach_session(session_id=1)
|
await service.attach_session(session_id=1)
|
||||||
@@ -125,8 +140,9 @@ async def test_attach_session_passes_env(service, db, tmux, harnesses):
|
|||||||
tmux.respawn_verified.assert_called()
|
tmux.respawn_verified.assert_called()
|
||||||
first_call = tmux.respawn_verified.call_args_list[0]
|
first_call = tmux.respawn_verified.call_args_list[0]
|
||||||
# env must be forwarded (passed as keyword or positional)
|
# env must be forwarded (passed as keyword or positional)
|
||||||
assert first_call.kwargs.get("env") == {"SESSION_VAR": "abc"} or \
|
assert first_call.kwargs.get("env") == {"SESSION_VAR": "abc"} or (
|
||||||
(len(first_call.args) >= 4 and first_call.args[3] == {"SESSION_VAR": "abc"})
|
len(first_call.args) >= 4 and first_call.args[3] == {"SESSION_VAR": "abc"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -135,18 +151,28 @@ async def test_attach_session_passes_env(service, db, tmux, harnesses):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_attach_session_dead_resume_ok_attaches_once(service, db, tmux, harnesses):
|
async def test_attach_session_dead_resume_ok_attaches_once(
|
||||||
|
service, db, tmux, harnesses
|
||||||
|
):
|
||||||
"""attach_session: resume ok → attach called once, no fallback spawn attempted."""
|
"""attach_session: resume ok → attach called once, no fallback spawn attempted."""
|
||||||
await service.create_session(project_id=1, harness_name="claude-code")
|
await service.create_session(project_id=1, harness_name="claude-code")
|
||||||
|
|
||||||
resume_cfg = MagicMock(command=["claude", "--resume", "sess-1"], env={}, cwd=Path("/tmp/myproj"))
|
resume_cfg = MagicMock(
|
||||||
|
command=["claude", "--resume", "sess-1"], env={}, cwd=Path("/tmp/myproj")
|
||||||
|
)
|
||||||
harnesses["claude-code"].build_resume_config = MagicMock(return_value=resume_cfg)
|
harnesses["claude-code"].build_resume_config = MagicMock(return_value=resume_cfg)
|
||||||
harnesses["claude-code"].build_spawn_config = MagicMock(
|
harnesses["claude-code"].build_spawn_config = MagicMock(
|
||||||
return_value=MagicMock(command=["claude", "--session-id", "sess-1"], env={}, cwd=Path("/tmp/myproj"))
|
return_value=MagicMock(
|
||||||
|
command=["claude", "--session-id", "sess-1"],
|
||||||
|
env={},
|
||||||
|
cwd=Path("/tmp/myproj"),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
tmux.is_alive = AsyncMock(return_value=False)
|
tmux.is_alive = AsyncMock(return_value=False)
|
||||||
tmux.respawn_verified = AsyncMock(return_value=SpawnResult(ok=True, window_id=None, error=""))
|
tmux.respawn_verified = AsyncMock(
|
||||||
|
return_value=SpawnResult(ok=True, window_id=None, error="")
|
||||||
|
)
|
||||||
tmux.attach = AsyncMock(return_value=True)
|
tmux.attach = AsyncMock(return_value=True)
|
||||||
|
|
||||||
result = await service.attach_session(session_id=1)
|
result = await service.attach_session(session_id=1)
|
||||||
@@ -160,20 +186,30 @@ async def test_attach_session_dead_resume_ok_attaches_once(service, db, tmux, ha
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_attach_session_dead_resume_fails_fallback_spawn(service, db, tmux, harnesses):
|
async def test_attach_session_dead_resume_fails_fallback_spawn(
|
||||||
|
service, db, tmux, harnesses
|
||||||
|
):
|
||||||
"""attach_session: resume dies → fallback spawn with session-id, attach called once."""
|
"""attach_session: resume dies → fallback spawn with session-id, attach called once."""
|
||||||
await service.create_session(project_id=1, harness_name="claude-code")
|
await service.create_session(project_id=1, harness_name="claude-code")
|
||||||
|
|
||||||
resume_cfg = MagicMock(command=["claude", "--resume", "sess-1"], env={}, cwd=Path("/tmp/myproj"))
|
resume_cfg = MagicMock(
|
||||||
spawn_cfg = MagicMock(command=["claude", "--session-id", "sess-1"], env={}, cwd=Path("/tmp/myproj"))
|
command=["claude", "--resume", "sess-1"], env={}, cwd=Path("/tmp/myproj")
|
||||||
|
)
|
||||||
|
spawn_cfg = MagicMock(
|
||||||
|
command=["claude", "--session-id", "sess-1"], env={}, cwd=Path("/tmp/myproj")
|
||||||
|
)
|
||||||
harnesses["claude-code"].build_resume_config = MagicMock(return_value=resume_cfg)
|
harnesses["claude-code"].build_resume_config = MagicMock(return_value=resume_cfg)
|
||||||
harnesses["claude-code"].build_spawn_config = MagicMock(return_value=spawn_cfg)
|
harnesses["claude-code"].build_spawn_config = MagicMock(return_value=spawn_cfg)
|
||||||
|
|
||||||
tmux.is_alive = AsyncMock(return_value=False)
|
tmux.is_alive = AsyncMock(return_value=False)
|
||||||
tmux.respawn_verified = AsyncMock(side_effect=[
|
tmux.respawn_verified = AsyncMock(
|
||||||
SpawnResult(ok=False, window_id=None, error="no transcript"), # resume fails
|
side_effect=[
|
||||||
|
SpawnResult(
|
||||||
|
ok=False, window_id=None, error="no transcript"
|
||||||
|
), # resume fails
|
||||||
SpawnResult(ok=True, window_id="@2", error=""), # fresh spawn ok
|
SpawnResult(ok=True, window_id="@2", error=""), # fresh spawn ok
|
||||||
])
|
]
|
||||||
|
)
|
||||||
tmux.attach = AsyncMock(return_value=True)
|
tmux.attach = AsyncMock(return_value=True)
|
||||||
|
|
||||||
result = await service.attach_session(session_id=1)
|
result = await service.attach_session(session_id=1)
|
||||||
@@ -189,17 +225,25 @@ async def test_attach_session_dead_resume_fails_fallback_spawn(service, db, tmux
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_attach_session_both_rungs_fail_returns_false(service, db, tmux, harnesses):
|
async def test_attach_session_both_rungs_fail_returns_false(
|
||||||
|
service, db, tmux, harnesses
|
||||||
|
):
|
||||||
"""attach_session: both resume and spawn fail → False, no attach."""
|
"""attach_session: both resume and spawn fail → False, no attach."""
|
||||||
await service.create_session(project_id=1, harness_name="claude-code")
|
await service.create_session(project_id=1, harness_name="claude-code")
|
||||||
|
|
||||||
resume_cfg = MagicMock(command=["claude", "--resume", "sess-1"], env={}, cwd=Path("/tmp/myproj"))
|
resume_cfg = MagicMock(
|
||||||
spawn_cfg = MagicMock(command=["claude", "--session-id", "sess-1"], env={}, cwd=Path("/tmp/myproj"))
|
command=["claude", "--resume", "sess-1"], env={}, cwd=Path("/tmp/myproj")
|
||||||
|
)
|
||||||
|
spawn_cfg = MagicMock(
|
||||||
|
command=["claude", "--session-id", "sess-1"], env={}, cwd=Path("/tmp/myproj")
|
||||||
|
)
|
||||||
harnesses["claude-code"].build_resume_config = MagicMock(return_value=resume_cfg)
|
harnesses["claude-code"].build_resume_config = MagicMock(return_value=resume_cfg)
|
||||||
harnesses["claude-code"].build_spawn_config = MagicMock(return_value=spawn_cfg)
|
harnesses["claude-code"].build_spawn_config = MagicMock(return_value=spawn_cfg)
|
||||||
|
|
||||||
tmux.is_alive = AsyncMock(return_value=False)
|
tmux.is_alive = AsyncMock(return_value=False)
|
||||||
tmux.respawn_verified = AsyncMock(return_value=SpawnResult(ok=False, window_id=None, error="fatal"))
|
tmux.respawn_verified = AsyncMock(
|
||||||
|
return_value=SpawnResult(ok=False, window_id=None, error="fatal")
|
||||||
|
)
|
||||||
tmux.attach = AsyncMock(return_value=True)
|
tmux.attach = AsyncMock(return_value=True)
|
||||||
|
|
||||||
result = await service.attach_session(session_id=1)
|
result = await service.attach_session(session_id=1)
|
||||||
@@ -219,7 +263,9 @@ def harnesses_no_capture():
|
|||||||
h = MagicMock()
|
h = MagicMock()
|
||||||
h.captures_session_id = False
|
h.captures_session_id = False
|
||||||
h.generate_session_id.return_value = "sess-nc"
|
h.generate_session_id.return_value = "sess-nc"
|
||||||
h.build_spawn_config.return_value = MagicMock(command=["claude"], env={}, cwd=Path("/tmp/myproj"))
|
h.build_spawn_config.return_value = MagicMock(
|
||||||
|
command=["claude"], env={}, cwd=Path("/tmp/myproj")
|
||||||
|
)
|
||||||
return {"claude-code": h}
|
return {"claude-code": h}
|
||||||
|
|
||||||
|
|
||||||
@@ -229,12 +275,16 @@ def harnesses_capture():
|
|||||||
h = MagicMock()
|
h = MagicMock()
|
||||||
h.captures_session_id = True
|
h.captures_session_id = True
|
||||||
h.generate_session_id.return_value = "placeholder-id"
|
h.generate_session_id.return_value = "placeholder-id"
|
||||||
h.build_spawn_config.return_value = MagicMock(command=["codex"], env={}, cwd=Path("/tmp/myproj"))
|
h.build_spawn_config.return_value = MagicMock(
|
||||||
|
command=["codex"], env={}, cwd=Path("/tmp/myproj")
|
||||||
|
)
|
||||||
return {"claude-code": h}
|
return {"claude-code": h}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_session_no_capture_flag_never_calls_capture(db, tmux, harnesses_no_capture):
|
async def test_create_session_no_capture_flag_never_calls_capture(
|
||||||
|
db, tmux, harnesses_no_capture
|
||||||
|
):
|
||||||
"""When captures_session_id=False, capture_session_id is never called."""
|
"""When captures_session_id=False, capture_session_id is never called."""
|
||||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_no_capture)
|
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_no_capture)
|
||||||
await service.create_session(project_id=1, harness_name="claude-code")
|
await service.create_session(project_id=1, harness_name="claude-code")
|
||||||
@@ -242,11 +292,17 @@ async def test_create_session_no_capture_flag_never_calls_capture(db, tmux, harn
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_session_capture_retries_until_success(db, tmux, harnesses_capture):
|
async def test_create_session_capture_retries_until_success(
|
||||||
|
db, tmux, harnesses_capture
|
||||||
|
):
|
||||||
"""captures_session_id=True: returns None twice then an id → retries and stores captured id."""
|
"""captures_session_id=True: returns None twice then an id → retries and stores captured id."""
|
||||||
import hqt.sessions.service as svc_mod
|
import hqt.sessions.service as svc_mod
|
||||||
|
|
||||||
harnesses_capture["claude-code"].capture_session_id.side_effect = [None, None, "captured-id-xyz"]
|
harnesses_capture["claude-code"].capture_session_id.side_effect = [
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"captured-id-xyz",
|
||||||
|
]
|
||||||
|
|
||||||
sleep_calls = []
|
sleep_calls = []
|
||||||
|
|
||||||
@@ -264,7 +320,9 @@ async def test_create_session_capture_retries_until_success(db, tmux, harnesses_
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_session_capture_all_none_keeps_placeholder(db, tmux, harnesses_capture, caplog):
|
async def test_create_session_capture_all_none_keeps_placeholder(
|
||||||
|
db, tmux, harnesses_capture, caplog
|
||||||
|
):
|
||||||
"""All capture attempts return None → placeholder id kept, no crash, warning logged."""
|
"""All capture attempts return None → placeholder id kept, no crash, warning logged."""
|
||||||
import logging
|
import logging
|
||||||
import hqt.sessions.service as svc_mod
|
import hqt.sessions.service as svc_mod
|
||||||
@@ -277,18 +335,27 @@ async def test_create_session_capture_all_none_keeps_placeholder(db, tmux, harne
|
|||||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_capture)
|
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_capture)
|
||||||
with caplog.at_level(logging.WARNING):
|
with caplog.at_level(logging.WARNING):
|
||||||
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
|
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
|
||||||
result = await service.create_session(project_id=1, harness_name="claude-code")
|
result = await service.create_session(
|
||||||
|
project_id=1, harness_name="claude-code"
|
||||||
|
)
|
||||||
|
|
||||||
assert result.session.harness_session_id == "placeholder-id"
|
assert result.session.harness_session_id == "placeholder-id"
|
||||||
# Should have attempted up to 6 times (CAPTURE_MAX_ATTEMPTS)
|
# Should have attempted up to 6 times (CAPTURE_MAX_ATTEMPTS)
|
||||||
assert harnesses_capture["claude-code"].capture_session_id.call_count == svc_mod.CAPTURE_MAX_ATTEMPTS
|
assert (
|
||||||
|
harnesses_capture["claude-code"].capture_session_id.call_count
|
||||||
|
== svc_mod.CAPTURE_MAX_ATTEMPTS
|
||||||
|
)
|
||||||
# Exhaustion must emit a warning
|
# Exhaustion must emit a warning
|
||||||
warning_messages = [r.message for r in caplog.records if r.levelno == logging.WARNING]
|
warning_messages = [
|
||||||
|
r.message for r in caplog.records if r.levelno == logging.WARNING
|
||||||
|
]
|
||||||
assert any("exhausted" in m for m in warning_messages)
|
assert any("exhausted" in m for m in warning_messages)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_session_capture_first_attempt_succeeds(db, tmux, harnesses_capture):
|
async def test_create_session_capture_first_attempt_succeeds(
|
||||||
|
db, tmux, harnesses_capture
|
||||||
|
):
|
||||||
"""captures_session_id=True: first attempt succeeds → no sleeps needed."""
|
"""captures_session_id=True: first attempt succeeds → no sleeps needed."""
|
||||||
import hqt.sessions.service as svc_mod
|
import hqt.sessions.service as svc_mod
|
||||||
|
|
||||||
@@ -316,6 +383,7 @@ async def test_create_session_capture_first_attempt_succeeds(db, tmux, harnesses
|
|||||||
async def test_list_sessions_dead_window(db, tmux, harnesses):
|
async def test_list_sessions_dead_window(db, tmux, harnesses):
|
||||||
"""list_sessions: dead window → status='dead', alive=False."""
|
"""list_sessions: dead window → status='dead', alive=False."""
|
||||||
from hqt.tmux.runner import WindowInfo
|
from hqt.tmux.runner import WindowInfo
|
||||||
|
|
||||||
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
|
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
|
||||||
project_id=1, harness_name="claude-code"
|
project_id=1, harness_name="claude-code"
|
||||||
)
|
)
|
||||||
@@ -344,6 +412,7 @@ async def test_list_sessions_missing_window_is_dead(db, tmux, harnesses):
|
|||||||
async def test_list_sessions_alive_parse_working(db, tmux, harnesses):
|
async def test_list_sessions_alive_parse_working(db, tmux, harnesses):
|
||||||
"""Alive window + harness parse returns 'working' → status='working'."""
|
"""Alive window + harness parse returns 'working' → status='working'."""
|
||||||
from hqt.tmux.runner import WindowInfo
|
from hqt.tmux.runner import WindowInfo
|
||||||
|
|
||||||
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
|
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
|
||||||
project_id=1, harness_name="claude-code"
|
project_id=1, harness_name="claude-code"
|
||||||
)
|
)
|
||||||
@@ -364,12 +433,15 @@ async def test_list_sessions_alive_parse_working(db, tmux, harnesses):
|
|||||||
async def test_list_sessions_alive_parse_none_recent_activity(db, tmux, harnesses):
|
async def test_list_sessions_alive_parse_none_recent_activity(db, tmux, harnesses):
|
||||||
"""Alive window + parse_status None + recent activity → status='active'."""
|
"""Alive window + parse_status None + recent activity → status='active'."""
|
||||||
from hqt.tmux.runner import WindowInfo
|
from hqt.tmux.runner import WindowInfo
|
||||||
|
|
||||||
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
|
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
|
||||||
project_id=1, harness_name="claude-code"
|
project_id=1, harness_name="claude-code"
|
||||||
)
|
)
|
||||||
|
|
||||||
now = 2000.0
|
now = 2000.0
|
||||||
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1998)} # 2s ago
|
tmux.poll_info.return_value = {
|
||||||
|
"hqt-1": WindowInfo(alive=True, last_activity=1998)
|
||||||
|
} # 2s ago
|
||||||
tmux.capture_pane = AsyncMock(return_value="some text")
|
tmux.capture_pane = AsyncMock(return_value="some text")
|
||||||
harnesses["claude-code"].parse_status = MagicMock(return_value=None)
|
harnesses["claude-code"].parse_status = MagicMock(return_value=None)
|
||||||
|
|
||||||
@@ -383,12 +455,15 @@ async def test_list_sessions_alive_parse_none_recent_activity(db, tmux, harnesse
|
|||||||
async def test_list_sessions_alive_parse_none_stale_activity(db, tmux, harnesses):
|
async def test_list_sessions_alive_parse_none_stale_activity(db, tmux, harnesses):
|
||||||
"""Alive window + parse_status None + stale activity → status='idle'."""
|
"""Alive window + parse_status None + stale activity → status='idle'."""
|
||||||
from hqt.tmux.runner import WindowInfo
|
from hqt.tmux.runner import WindowInfo
|
||||||
|
|
||||||
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
|
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
|
||||||
project_id=1, harness_name="claude-code"
|
project_id=1, harness_name="claude-code"
|
||||||
)
|
)
|
||||||
|
|
||||||
now = 2000.0
|
now = 2000.0
|
||||||
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1990)} # 10s ago
|
tmux.poll_info.return_value = {
|
||||||
|
"hqt-1": WindowInfo(alive=True, last_activity=1990)
|
||||||
|
} # 10s ago
|
||||||
tmux.capture_pane = AsyncMock(return_value="some text")
|
tmux.capture_pane = AsyncMock(return_value="some text")
|
||||||
harnesses["claude-code"].parse_status = MagicMock(return_value=None)
|
harnesses["claude-code"].parse_status = MagicMock(return_value=None)
|
||||||
|
|
||||||
@@ -402,6 +477,7 @@ async def test_list_sessions_alive_parse_none_stale_activity(db, tmux, harnesses
|
|||||||
async def test_list_sessions_capture_empty_falls_back_to_activity(db, tmux, harnesses):
|
async def test_list_sessions_capture_empty_falls_back_to_activity(db, tmux, harnesses):
|
||||||
"""capture_pane returns empty text → activity-based fallback, no crash."""
|
"""capture_pane returns empty text → activity-based fallback, no crash."""
|
||||||
from hqt.tmux.runner import WindowInfo
|
from hqt.tmux.runner import WindowInfo
|
||||||
|
|
||||||
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
|
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
|
||||||
project_id=1, harness_name="claude-code"
|
project_id=1, harness_name="claude-code"
|
||||||
)
|
)
|
||||||
@@ -423,6 +499,7 @@ async def test_list_sessions_capture_empty_falls_back_to_activity(db, tmux, harn
|
|||||||
async def test_list_sessions_alive_parse_waiting(db, tmux, harnesses):
|
async def test_list_sessions_alive_parse_waiting(db, tmux, harnesses):
|
||||||
"""Alive window + parse_status 'waiting' → status='waiting'."""
|
"""Alive window + parse_status 'waiting' → status='waiting'."""
|
||||||
from hqt.tmux.runner import WindowInfo
|
from hqt.tmux.runner import WindowInfo
|
||||||
|
|
||||||
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
|
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
|
||||||
project_id=1, harness_name="claude-code"
|
project_id=1, harness_name="claude-code"
|
||||||
)
|
)
|
||||||
@@ -449,7 +526,9 @@ async def test_sync_window_labels_sets_status_symbol(db, tmux, harnesses):
|
|||||||
from hqt.tmux.runner import WindowInfo
|
from hqt.tmux.runner import WindowInfo
|
||||||
|
|
||||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
||||||
await service.create_session(project_id=1, harness_name="claude-code", nickname="mywork")
|
await service.create_session(
|
||||||
|
project_id=1, harness_name="claude-code", nickname="mywork"
|
||||||
|
)
|
||||||
|
|
||||||
now = 2000.0
|
now = 2000.0
|
||||||
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1999)}
|
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1999)}
|
||||||
@@ -471,7 +550,9 @@ async def test_sync_window_labels_dead_window_uses_dead_glyph(db, tmux, harnesse
|
|||||||
from hqt.tmux.runner import WindowInfo
|
from hqt.tmux.runner import WindowInfo
|
||||||
|
|
||||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
||||||
await service.create_session(project_id=1, harness_name="claude-code") # no nickname
|
await service.create_session(
|
||||||
|
project_id=1, harness_name="claude-code"
|
||||||
|
) # no nickname
|
||||||
|
|
||||||
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=False, last_activity=0)}
|
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=False, last_activity=0)}
|
||||||
tmux.set_window_label = AsyncMock()
|
tmux.set_window_label = AsyncMock()
|
||||||
@@ -533,16 +614,22 @@ async def test_list_sessions_real_configurator_waiting(db):
|
|||||||
# spec=TmuxManager: only methods that exist on TmuxManager are accessible.
|
# spec=TmuxManager: only methods that exist on TmuxManager are accessible.
|
||||||
# capture_pane MUST be on TmuxManager (via the delegate) for the spec to allow it.
|
# capture_pane MUST be on TmuxManager (via the delegate) for the spec to allow it.
|
||||||
tmux_spec = MagicMock(spec=TmuxManager)
|
tmux_spec = MagicMock(spec=TmuxManager)
|
||||||
tmux_spec.spawn = AsyncMock(return_value=SpawnResult(ok=True, window_id="@1", error=""))
|
tmux_spec.spawn = AsyncMock(
|
||||||
|
return_value=SpawnResult(ok=True, window_id="@1", error="")
|
||||||
|
)
|
||||||
tmux_spec.kill = AsyncMock()
|
tmux_spec.kill = AsyncMock()
|
||||||
tmux_spec.is_alive = AsyncMock(return_value=True)
|
tmux_spec.is_alive = AsyncMock(return_value=True)
|
||||||
tmux_spec.poll_info = AsyncMock(return_value={})
|
tmux_spec.poll_info = AsyncMock(return_value={})
|
||||||
# capture_pane returns realistic "waiting" pane text; the spec must allow this
|
# capture_pane returns realistic "waiting" pane text; the spec must allow this
|
||||||
# attribute — fails with AttributeError before TmuxManager.capture_pane delegate exists.
|
# attribute — fails with AttributeError before TmuxManager.capture_pane delegate exists.
|
||||||
tmux_spec.capture_pane = AsyncMock(return_value="Do you want to proceed?\n❯ 1. Yes\n")
|
tmux_spec.capture_pane = AsyncMock(
|
||||||
|
return_value="Do you want to proceed?\n❯ 1. Yes\n"
|
||||||
|
)
|
||||||
tmux_spec.attach = AsyncMock()
|
tmux_spec.attach = AsyncMock()
|
||||||
tmux_spec.window_exists = AsyncMock(return_value=True)
|
tmux_spec.window_exists = AsyncMock(return_value=True)
|
||||||
tmux_spec.respawn_verified = AsyncMock(return_value=SpawnResult(ok=True, window_id=None, error=""))
|
tmux_spec.respawn_verified = AsyncMock(
|
||||||
|
return_value=SpawnResult(ok=True, window_id=None, error="")
|
||||||
|
)
|
||||||
|
|
||||||
configurator = ClaudeConfigurator()
|
configurator = ClaudeConfigurator()
|
||||||
harnesses = {"claude-code": configurator}
|
harnesses = {"claude-code": configurator}
|
||||||
@@ -553,7 +640,9 @@ async def test_list_sessions_real_configurator_waiting(db):
|
|||||||
|
|
||||||
now = 2000.0
|
now = 2000.0
|
||||||
window_name = "hqt-1"
|
window_name = "hqt-1"
|
||||||
tmux_spec.poll_info.return_value = {window_name: WindowInfo(alive=True, last_activity=1999)}
|
tmux_spec.poll_info.return_value = {
|
||||||
|
window_name: WindowInfo(alive=True, last_activity=1999)
|
||||||
|
}
|
||||||
|
|
||||||
result = await service.list_sessions(project_id=1, now=now)
|
result = await service.list_sessions(project_id=1, now=now)
|
||||||
|
|
||||||
@@ -615,17 +704,25 @@ def harnesses_capture_fallback():
|
|||||||
h.name = "claude-code"
|
h.name = "claude-code"
|
||||||
h.captures_session_id = True
|
h.captures_session_id = True
|
||||||
h.generate_session_id.return_value = "placeholder-id"
|
h.generate_session_id.return_value = "placeholder-id"
|
||||||
h.build_spawn_config.return_value = MagicMock(command=["codex"], env={}, cwd=Path("/tmp/myproj"))
|
h.build_spawn_config.return_value = MagicMock(
|
||||||
h.build_resume_config.return_value = MagicMock(command=["codex", "resume", "placeholder-id"], env={}, cwd=Path("/tmp/myproj"))
|
command=["codex"], env={}, cwd=Path("/tmp/myproj")
|
||||||
|
)
|
||||||
|
h.build_resume_config.return_value = MagicMock(
|
||||||
|
command=["codex", "resume", "placeholder-id"], env={}, cwd=Path("/tmp/myproj")
|
||||||
|
)
|
||||||
return {"claude-code": h}
|
return {"claude-code": h}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_respawn_fallback_rung2_updates_harness_session_id(db, tmux, harnesses_capture_fallback):
|
async def test_respawn_fallback_rung2_updates_harness_session_id(
|
||||||
|
db, tmux, harnesses_capture_fallback
|
||||||
|
):
|
||||||
"""Rung-2 success + captures_session_id=True + capture returns new id → sess.harness_session_id updated and committed."""
|
"""Rung-2 success + captures_session_id=True + capture returns new id → sess.harness_session_id updated and committed."""
|
||||||
import hqt.sessions.service as svc_mod
|
import hqt.sessions.service as svc_mod
|
||||||
|
|
||||||
harnesses_capture_fallback["claude-code"].capture_session_id.return_value = "new-codex-id"
|
harnesses_capture_fallback[
|
||||||
|
"claude-code"
|
||||||
|
].capture_session_id.return_value = "new-codex-id"
|
||||||
|
|
||||||
async def fake_sleep(t):
|
async def fake_sleep(t):
|
||||||
pass
|
pass
|
||||||
@@ -638,13 +735,19 @@ async def test_respawn_fallback_rung2_updates_harness_session_id(db, tmux, harne
|
|||||||
|
|
||||||
# Simulate rung-1 resume fails, rung-2 fresh spawn succeeds
|
# Simulate rung-1 resume fails, rung-2 fresh spawn succeeds
|
||||||
harnesses_capture_fallback["claude-code"].capture_session_id.reset_mock()
|
harnesses_capture_fallback["claude-code"].capture_session_id.reset_mock()
|
||||||
harnesses_capture_fallback["claude-code"].capture_session_id.return_value = "new-codex-id-after-rung2"
|
harnesses_capture_fallback[
|
||||||
|
"claude-code"
|
||||||
|
].capture_session_id.return_value = "new-codex-id-after-rung2"
|
||||||
|
|
||||||
tmux.is_alive = AsyncMock(return_value=False)
|
tmux.is_alive = AsyncMock(return_value=False)
|
||||||
tmux.respawn_verified = AsyncMock(side_effect=[
|
tmux.respawn_verified = AsyncMock(
|
||||||
SpawnResult(ok=False, window_id=None, error="no transcript"), # rung-1 resume fails
|
side_effect=[
|
||||||
|
SpawnResult(
|
||||||
|
ok=False, window_id=None, error="no transcript"
|
||||||
|
), # rung-1 resume fails
|
||||||
SpawnResult(ok=True, window_id="@2", error=""), # rung-2 fresh spawn ok
|
SpawnResult(ok=True, window_id="@2", error=""), # rung-2 fresh spawn ok
|
||||||
])
|
]
|
||||||
|
)
|
||||||
tmux.attach = AsyncMock(return_value=True)
|
tmux.attach = AsyncMock(return_value=True)
|
||||||
|
|
||||||
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
|
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
|
||||||
@@ -653,17 +756,61 @@ async def test_respawn_fallback_rung2_updates_harness_session_id(db, tmux, harne
|
|||||||
assert ok is True
|
assert ok is True
|
||||||
# harness_session_id must now be the newly captured id
|
# harness_session_id must now be the newly captured id
|
||||||
from hqt.db.models import Session as DBSession
|
from hqt.db.models import Session as DBSession
|
||||||
|
|
||||||
updated = db.get(DBSession, sess_id)
|
updated = db.get(DBSession, sess_id)
|
||||||
assert updated.harness_session_id == "new-codex-id-after-rung2"
|
assert updated.harness_session_id == "new-codex-id-after-rung2"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_respawn_fallback_rung2_capture_exhausted_keeps_old_id(db, tmux, harnesses_capture_fallback, caplog):
|
async def test_attach_late_capture_updates_id_before_resume(
|
||||||
|
db, tmux, harnesses_capture_fallback
|
||||||
|
):
|
||||||
|
"""If create-time Codex capture missed, attach captures the real id before resuming."""
|
||||||
|
import hqt.sessions.service as svc_mod
|
||||||
|
|
||||||
|
harness = harnesses_capture_fallback["claude-code"]
|
||||||
|
harness.capture_session_id.side_effect = [None] * svc_mod.CAPTURE_MAX_ATTEMPTS + [
|
||||||
|
"late-codex-id"
|
||||||
|
]
|
||||||
|
|
||||||
|
async def fake_sleep(t):
|
||||||
|
pass
|
||||||
|
|
||||||
|
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_capture_fallback)
|
||||||
|
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
|
||||||
|
result = await service.create_session(project_id=1, harness_name="claude-code")
|
||||||
|
|
||||||
|
assert result.session.harness_session_id == "placeholder-id"
|
||||||
|
|
||||||
|
tmux.is_alive = AsyncMock(return_value=False)
|
||||||
|
tmux.respawn_verified = AsyncMock(
|
||||||
|
return_value=SpawnResult(ok=True, window_id="@2", error="")
|
||||||
|
)
|
||||||
|
tmux.attach = AsyncMock(return_value=True)
|
||||||
|
|
||||||
|
ok = await service.attach_session(session_id=result.session.id)
|
||||||
|
|
||||||
|
assert ok is True
|
||||||
|
harness.build_resume_config.assert_called_with(
|
||||||
|
Path("/tmp/myproj"), "late-codex-id", result.session.model
|
||||||
|
)
|
||||||
|
from hqt.db.models import Session as DBSession
|
||||||
|
|
||||||
|
updated = db.get(DBSession, result.session.id)
|
||||||
|
assert updated.harness_session_id == "late-codex-id"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_respawn_fallback_rung2_capture_exhausted_keeps_old_id(
|
||||||
|
db, tmux, harnesses_capture_fallback, caplog
|
||||||
|
):
|
||||||
"""Rung-2 success + capture exhausted → old id kept + warning logged."""
|
"""Rung-2 success + capture exhausted → old id kept + warning logged."""
|
||||||
import logging
|
import logging
|
||||||
import hqt.sessions.service as svc_mod
|
import hqt.sessions.service as svc_mod
|
||||||
|
|
||||||
harnesses_capture_fallback["claude-code"].capture_session_id.return_value = "placeholder-id"
|
harnesses_capture_fallback[
|
||||||
|
"claude-code"
|
||||||
|
].capture_session_id.return_value = "placeholder-id"
|
||||||
|
|
||||||
async def fake_sleep(t):
|
async def fake_sleep(t):
|
||||||
pass
|
pass
|
||||||
@@ -680,10 +827,12 @@ async def test_respawn_fallback_rung2_capture_exhausted_keeps_old_id(db, tmux, h
|
|||||||
harnesses_capture_fallback["claude-code"].capture_session_id.return_value = None
|
harnesses_capture_fallback["claude-code"].capture_session_id.return_value = None
|
||||||
|
|
||||||
tmux.is_alive = AsyncMock(return_value=False)
|
tmux.is_alive = AsyncMock(return_value=False)
|
||||||
tmux.respawn_verified = AsyncMock(side_effect=[
|
tmux.respawn_verified = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
SpawnResult(ok=False, window_id=None, error="no transcript"),
|
SpawnResult(ok=False, window_id=None, error="no transcript"),
|
||||||
SpawnResult(ok=True, window_id="@2", error=""),
|
SpawnResult(ok=True, window_id="@2", error=""),
|
||||||
])
|
]
|
||||||
|
)
|
||||||
tmux.attach = AsyncMock(return_value=True)
|
tmux.attach = AsyncMock(return_value=True)
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING):
|
with caplog.at_level(logging.WARNING):
|
||||||
@@ -692,11 +841,14 @@ async def test_respawn_fallback_rung2_capture_exhausted_keeps_old_id(db, tmux, h
|
|||||||
|
|
||||||
assert ok is True
|
assert ok is True
|
||||||
from hqt.db.models import Session as DBSession
|
from hqt.db.models import Session as DBSession
|
||||||
|
|
||||||
updated = db.get(DBSession, sess_id)
|
updated = db.get(DBSession, sess_id)
|
||||||
# Old id preserved
|
# Old id preserved
|
||||||
assert updated.harness_session_id == old_id
|
assert updated.harness_session_id == old_id
|
||||||
# Warning emitted
|
# Warning emitted
|
||||||
warning_messages = [r.message for r in caplog.records if r.levelno == logging.WARNING]
|
warning_messages = [
|
||||||
|
r.message for r in caplog.records if r.levelno == logging.WARNING
|
||||||
|
]
|
||||||
assert any("exhausted" in m for m in warning_messages)
|
assert any("exhausted" in m for m in warning_messages)
|
||||||
|
|
||||||
|
|
||||||
@@ -707,8 +859,12 @@ async def test_respawn_fallback_rung2_no_capture_when_flag_false(db, tmux):
|
|||||||
h.name = "claude-code"
|
h.name = "claude-code"
|
||||||
h.captures_session_id = False
|
h.captures_session_id = False
|
||||||
h.generate_session_id.return_value = "sess-nc"
|
h.generate_session_id.return_value = "sess-nc"
|
||||||
h.build_spawn_config.return_value = MagicMock(command=["claude"], env={}, cwd=Path("/tmp/myproj"))
|
h.build_spawn_config.return_value = MagicMock(
|
||||||
h.build_resume_config.return_value = MagicMock(command=["claude", "--resume", "sess-nc"], env={}, cwd=Path("/tmp/myproj"))
|
command=["claude"], env={}, cwd=Path("/tmp/myproj")
|
||||||
|
)
|
||||||
|
h.build_resume_config.return_value = MagicMock(
|
||||||
|
command=["claude", "--resume", "sess-nc"], env={}, cwd=Path("/tmp/myproj")
|
||||||
|
)
|
||||||
harnesses_nc = {"claude-code": h}
|
harnesses_nc = {"claude-code": h}
|
||||||
|
|
||||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_nc)
|
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_nc)
|
||||||
@@ -718,10 +874,12 @@ async def test_respawn_fallback_rung2_no_capture_when_flag_false(db, tmux):
|
|||||||
h.capture_session_id.reset_mock()
|
h.capture_session_id.reset_mock()
|
||||||
|
|
||||||
tmux.is_alive = AsyncMock(return_value=False)
|
tmux.is_alive = AsyncMock(return_value=False)
|
||||||
tmux.respawn_verified = AsyncMock(side_effect=[
|
tmux.respawn_verified = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
SpawnResult(ok=False, window_id=None, error="no transcript"),
|
SpawnResult(ok=False, window_id=None, error="no transcript"),
|
||||||
SpawnResult(ok=True, window_id="@2", error=""),
|
SpawnResult(ok=True, window_id="@2", error=""),
|
||||||
])
|
]
|
||||||
|
)
|
||||||
tmux.attach = AsyncMock(return_value=True)
|
tmux.attach = AsyncMock(return_value=True)
|
||||||
|
|
||||||
await service.attach_session(session_id=sess_id)
|
await service.attach_session(session_id=sess_id)
|
||||||
@@ -739,7 +897,9 @@ async def test_create_session_no_capture_when_spawn_fails(db, tmux, harnesses_ca
|
|||||||
"""create_session: spawn fails → capture_session_id must NOT be called."""
|
"""create_session: spawn fails → capture_session_id must NOT be called."""
|
||||||
import hqt.sessions.service as svc_mod
|
import hqt.sessions.service as svc_mod
|
||||||
|
|
||||||
tmux.spawn.return_value = SpawnResult(ok=False, window_id=None, error="spawn failed")
|
tmux.spawn.return_value = SpawnResult(
|
||||||
|
ok=False, window_id=None, error="spawn failed"
|
||||||
|
)
|
||||||
|
|
||||||
async def fake_sleep(t):
|
async def fake_sleep(t):
|
||||||
pass
|
pass
|
||||||
@@ -763,7 +923,9 @@ async def test_create_session_returns_result_dataclass(service, db, tmux, harnes
|
|||||||
"""create_session returns a CreateSessionResult with spawn_ok=True on success."""
|
"""create_session returns a CreateSessionResult with spawn_ok=True on success."""
|
||||||
from hqt.sessions.service import CreateSessionResult
|
from hqt.sessions.service import CreateSessionResult
|
||||||
|
|
||||||
result = await service.create_session(project_id=1, harness_name="claude-code", nickname="test")
|
result = await service.create_session(
|
||||||
|
project_id=1, harness_name="claude-code", nickname="test"
|
||||||
|
)
|
||||||
assert isinstance(result, CreateSessionResult)
|
assert isinstance(result, CreateSessionResult)
|
||||||
assert result.spawn_ok is True
|
assert result.spawn_ok is True
|
||||||
assert result.spawn_error == ""
|
assert result.spawn_error == ""
|
||||||
@@ -775,7 +937,9 @@ async def test_create_session_result_propagates_spawn_failure(db, tmux, harnesse
|
|||||||
"""create_session result reflects spawn failure."""
|
"""create_session result reflects spawn failure."""
|
||||||
from hqt.sessions.service import CreateSessionResult
|
from hqt.sessions.service import CreateSessionResult
|
||||||
|
|
||||||
tmux.spawn.return_value = SpawnResult(ok=False, window_id=None, error="command not found: claude")
|
tmux.spawn.return_value = SpawnResult(
|
||||||
|
ok=False, window_id=None, error="command not found: claude"
|
||||||
|
)
|
||||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
||||||
result = await service.create_session(project_id=1, harness_name="claude-code")
|
result = await service.create_session(project_id=1, harness_name="claude-code")
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ from hqt.skills.registry import SkillRegistry
|
|||||||
def test_discover_skills(tmp_path: Path):
|
def test_discover_skills(tmp_path: Path):
|
||||||
skill_dir = tmp_path / "my-skill"
|
skill_dir = tmp_path / "my-skill"
|
||||||
skill_dir.mkdir()
|
skill_dir.mkdir()
|
||||||
(skill_dir / "SKILL.md").write_text("---\nname: my-skill\ndescription: A test skill\n---\nSkill content here")
|
(skill_dir / "SKILL.md").write_text(
|
||||||
|
"---\nname: my-skill\ndescription: A test skill\n---\nSkill content here"
|
||||||
|
)
|
||||||
|
|
||||||
registry = SkillRegistry(tmp_path)
|
registry = SkillRegistry(tmp_path)
|
||||||
skills = registry.discover()
|
skills = registry.discover()
|
||||||
|
|||||||
+104
-24
@@ -17,7 +17,11 @@ async def test_new_window(runner):
|
|||||||
runner._exec.side_effect = [
|
runner._exec.side_effect = [
|
||||||
(0, "0\n", ""), # list-windows -F '#{window_index}' (next-index query)
|
(0, "0\n", ""), # list-windows -F '#{window_index}' (next-index query)
|
||||||
(0, "@1\n", ""), # new-window -P -F
|
(0, "@1\n", ""), # new-window -P -F
|
||||||
(0, "", ""), # set-option (combined: remain-on-exit + allow-rename + automatic-rename)
|
(
|
||||||
|
0,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
), # set-option (combined: remain-on-exit + allow-rename + automatic-rename)
|
||||||
(0, "", ""), # respawn-pane
|
(0, "", ""), # respawn-pane
|
||||||
]
|
]
|
||||||
result = await runner.new_window("hqt-1", "/tmp", "kiro-cli chat")
|
result = await runner.new_window("hqt-1", "/tmp", "kiro-cli chat")
|
||||||
@@ -26,9 +30,23 @@ async def test_new_window(runner):
|
|||||||
# options, then the per-window Frappé theme options (see new_window).
|
# options, then the per-window Frappé theme options (see new_window).
|
||||||
set_call = runner._exec.call_args_list[2].args
|
set_call = runner._exec.call_args_list[2].args
|
||||||
assert set_call[:17] == (
|
assert set_call[:17] == (
|
||||||
"set-option", "-t", "hqt-main:=hqt-1", "remain-on-exit", "on",
|
"set-option",
|
||||||
";", "set-option", "-t", "hqt-main:=hqt-1", "allow-rename", "off",
|
"-t",
|
||||||
";", "set-option", "-t", "hqt-main:=hqt-1", "automatic-rename", "off",
|
"hqt-main:=hqt-1",
|
||||||
|
"remain-on-exit",
|
||||||
|
"on",
|
||||||
|
";",
|
||||||
|
"set-option",
|
||||||
|
"-t",
|
||||||
|
"hqt-main:=hqt-1",
|
||||||
|
"allow-rename",
|
||||||
|
"off",
|
||||||
|
";",
|
||||||
|
"set-option",
|
||||||
|
"-t",
|
||||||
|
"hqt-main:=hqt-1",
|
||||||
|
"automatic-rename",
|
||||||
|
"off",
|
||||||
)
|
)
|
||||||
assert "window-status-current-style" in set_call
|
assert "window-status-current-style" in set_call
|
||||||
# respawn-pane runs the actual command
|
# respawn-pane runs the actual command
|
||||||
@@ -61,7 +79,9 @@ async def test_new_window_targets_explicit_free_index(runner):
|
|||||||
new_win_args = runner._exec.call_args_list[1].args
|
new_win_args = runner._exec.call_args_list[1].args
|
||||||
assert "new-window" in new_win_args
|
assert "new-window" in new_win_args
|
||||||
# Explicit free index = max(0,1,2)+1 = 3, targeted as "<session>:3".
|
# Explicit free index = max(0,1,2)+1 = 3, targeted as "<session>:3".
|
||||||
assert "hqt-main:3" in new_win_args, f"expected explicit free index target, got {new_win_args}"
|
assert "hqt-main:3" in new_win_args, (
|
||||||
|
f"expected explicit free index target, got {new_win_args}"
|
||||||
|
)
|
||||||
# Must NOT use the bare session name as the new-window target.
|
# Must NOT use the bare session name as the new-window target.
|
||||||
ti = new_win_args.index("-t")
|
ti = new_win_args.index("-t")
|
||||||
assert new_win_args[ti + 1] == "hqt-main:3"
|
assert new_win_args[ti + 1] == "hqt-main:3"
|
||||||
@@ -102,7 +122,13 @@ async def test_select_window(runner):
|
|||||||
async def test_respawn_pane(runner):
|
async def test_respawn_pane(runner):
|
||||||
await runner.respawn_pane("hqt-1", "kiro-cli chat", "/tmp")
|
await runner.respawn_pane("hqt-1", "kiro-cli chat", "/tmp")
|
||||||
runner._exec.assert_called_with(
|
runner._exec.assert_called_with(
|
||||||
"respawn-pane", "-t", "hqt-main:=hqt-1", "-k", "-c", "/tmp", "kiro-cli chat",
|
"respawn-pane",
|
||||||
|
"-t",
|
||||||
|
"hqt-main:=hqt-1",
|
||||||
|
"-k",
|
||||||
|
"-c",
|
||||||
|
"/tmp",
|
||||||
|
"kiro-cli chat",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -226,9 +252,23 @@ async def test_new_window_race_free_sequence(runner):
|
|||||||
|
|
||||||
set_args = calls[2].args
|
set_args = calls[2].args
|
||||||
assert set_args[:17] == (
|
assert set_args[:17] == (
|
||||||
"set-option", "-t", "hqt-main:=hqt-1", "remain-on-exit", "on",
|
"set-option",
|
||||||
";", "set-option", "-t", "hqt-main:=hqt-1", "allow-rename", "off",
|
"-t",
|
||||||
";", "set-option", "-t", "hqt-main:=hqt-1", "automatic-rename", "off",
|
"hqt-main:=hqt-1",
|
||||||
|
"remain-on-exit",
|
||||||
|
"on",
|
||||||
|
";",
|
||||||
|
"set-option",
|
||||||
|
"-t",
|
||||||
|
"hqt-main:=hqt-1",
|
||||||
|
"allow-rename",
|
||||||
|
"off",
|
||||||
|
";",
|
||||||
|
"set-option",
|
||||||
|
"-t",
|
||||||
|
"hqt-main:=hqt-1",
|
||||||
|
"automatic-rename",
|
||||||
|
"off",
|
||||||
)
|
)
|
||||||
# Window-scoped theme options painted on this window, current-style = peach.
|
# Window-scoped theme options painted on this window, current-style = peach.
|
||||||
assert "window-status-current-style" in set_args
|
assert "window-status-current-style" in set_args
|
||||||
@@ -254,7 +294,9 @@ async def test_new_window_with_env(runner):
|
|||||||
(0, "", ""),
|
(0, "", ""),
|
||||||
(0, "", ""),
|
(0, "", ""),
|
||||||
]
|
]
|
||||||
window_id = await runner.new_window("hqt-2", "/tmp", "claude", env={"FOO": "bar", "X": "1"})
|
window_id = await runner.new_window(
|
||||||
|
"hqt-2", "/tmp", "claude", env={"FOO": "bar", "X": "1"}
|
||||||
|
)
|
||||||
assert window_id == "@7"
|
assert window_id == "@7"
|
||||||
|
|
||||||
calls_list = runner._exec.call_args_list
|
calls_list = runner._exec.call_args_list
|
||||||
@@ -440,7 +482,9 @@ async def test_spawn_shlex_join(runner):
|
|||||||
]
|
]
|
||||||
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
|
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
|
||||||
mgr = TmuxManager(runner)
|
mgr = TmuxManager(runner)
|
||||||
req = SpawnRequest(window_name="hqt-1", command=["my cmd", "arg with space"], cwd="/tmp")
|
req = SpawnRequest(
|
||||||
|
window_name="hqt-1", command=["my cmd", "arg with space"], cwd="/tmp"
|
||||||
|
)
|
||||||
await mgr.spawn(req)
|
await mgr.spawn(req)
|
||||||
|
|
||||||
# The respawn-pane call (now the 4th _exec call) should have the shlex-joined string
|
# The respawn-pane call (now the 4th _exec call) should have the shlex-joined string
|
||||||
@@ -449,6 +493,7 @@ async def test_spawn_shlex_join(runner):
|
|||||||
joined_cmd = respawn_call[-1]
|
joined_cmd = respawn_call[-1]
|
||||||
# shlex.join of ["my cmd", "arg with space"] → "'my cmd' 'arg with space'"
|
# shlex.join of ["my cmd", "arg with space"] → "'my cmd' 'arg with space'"
|
||||||
import shlex
|
import shlex
|
||||||
|
|
||||||
expected = shlex.join(["my cmd", "arg with space"])
|
expected = shlex.join(["my cmd", "arg with space"])
|
||||||
assert joined_cmd == expected
|
assert joined_cmd == expected
|
||||||
|
|
||||||
@@ -464,7 +509,9 @@ async def test_spawn_passes_env(runner):
|
|||||||
]
|
]
|
||||||
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
|
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
|
||||||
mgr = TmuxManager(runner)
|
mgr = TmuxManager(runner)
|
||||||
req = SpawnRequest(window_name="hqt-env", command=["bash"], cwd="/tmp", env={"MY_VAR": "hello"})
|
req = SpawnRequest(
|
||||||
|
window_name="hqt-env", command=["bash"], cwd="/tmp", env={"MY_VAR": "hello"}
|
||||||
|
)
|
||||||
result = await mgr.spawn(req)
|
result = await mgr.spawn(req)
|
||||||
assert result.ok is True
|
assert result.ok is True
|
||||||
|
|
||||||
@@ -480,11 +527,12 @@ async def test_spawn_passes_env(runner):
|
|||||||
assert "MY_VAR=hello" in respawn_args
|
assert "MY_VAR=hello" in respawn_args
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_respawn_pane_with_env(runner):
|
async def test_respawn_pane_with_env(runner):
|
||||||
"""respawn_pane passes -e KEY=VALUE flags when env is provided."""
|
"""respawn_pane passes -e KEY=VALUE flags when env is provided."""
|
||||||
await runner.respawn_pane("hqt-1", "claude", "/tmp", env={"MYKEY": "myval", "A": "b"})
|
await runner.respawn_pane(
|
||||||
|
"hqt-1", "claude", "/tmp", env={"MYKEY": "myval", "A": "b"}
|
||||||
|
)
|
||||||
call_args = runner._exec.call_args_list[0].args
|
call_args = runner._exec.call_args_list[0].args
|
||||||
assert "respawn-pane" in call_args
|
assert "respawn-pane" in call_args
|
||||||
assert "-e" in call_args
|
assert "-e" in call_args
|
||||||
@@ -501,7 +549,6 @@ async def test_respawn_pane_no_env(runner):
|
|||||||
assert "-e" not in call_args
|
assert "-e" not in call_args
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_spawn_verify_dead_empty_text_returns_fallback_error(runner):
|
async def test_spawn_verify_dead_empty_text_returns_fallback_error(runner):
|
||||||
"""When verify_window_alive returns (False, ''), spawn returns the fallback error message."""
|
"""When verify_window_alive returns (False, ''), spawn returns the fallback error message."""
|
||||||
@@ -579,7 +626,9 @@ async def test_respawn_verified_window_gone_branch(runner):
|
|||||||
(0, "", ""), # respawn-pane step 3
|
(0, "", ""), # respawn-pane step 3
|
||||||
]
|
]
|
||||||
mgr = TmuxManager(runner)
|
mgr = TmuxManager(runner)
|
||||||
result = await mgr.respawn_verified("hqt-gone", ["claude", "--resume", "abc"], "/tmp")
|
result = await mgr.respawn_verified(
|
||||||
|
"hqt-gone", ["claude", "--resume", "abc"], "/tmp"
|
||||||
|
)
|
||||||
assert result.ok is True
|
assert result.ok is True
|
||||||
assert result.window_id == "@9"
|
assert result.window_id == "@9"
|
||||||
|
|
||||||
@@ -593,7 +642,9 @@ async def test_respawn_verified_window_gone_new_window_fails(runner):
|
|||||||
(1, "", "session not found"), # new-window fails
|
(1, "", "session not found"), # new-window fails
|
||||||
]
|
]
|
||||||
mgr = TmuxManager(runner)
|
mgr = TmuxManager(runner)
|
||||||
result = await mgr.respawn_verified("hqt-gone", ["claude", "--resume", "abc"], "/tmp")
|
result = await mgr.respawn_verified(
|
||||||
|
"hqt-gone", ["claude", "--resume", "abc"], "/tmp"
|
||||||
|
)
|
||||||
assert result.ok is False
|
assert result.ok is False
|
||||||
assert result.error != ""
|
assert result.error != ""
|
||||||
|
|
||||||
@@ -621,6 +672,7 @@ async def test_respawn_verified_forwards_env(runner):
|
|||||||
async def test_respawn_verified_uses_shlex_join(runner):
|
async def test_respawn_verified_uses_shlex_join(runner):
|
||||||
"""respawn_verified uses shlex.join so args with spaces are properly quoted."""
|
"""respawn_verified uses shlex.join so args with spaces are properly quoted."""
|
||||||
import shlex
|
import shlex
|
||||||
|
|
||||||
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
|
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
|
||||||
runner._exec.side_effect = [
|
runner._exec.side_effect = [
|
||||||
(0, "hqt-1\n", ""), # has_window → True
|
(0, "hqt-1\n", ""), # has_window → True
|
||||||
@@ -647,7 +699,9 @@ async def test_respawn_verified_window_gone_forwards_env(runner):
|
|||||||
(0, "", ""), # respawn-pane step 3
|
(0, "", ""), # respawn-pane step 3
|
||||||
]
|
]
|
||||||
mgr = TmuxManager(runner)
|
mgr = TmuxManager(runner)
|
||||||
result = await mgr.respawn_verified("hqt-gone", ["bash"], "/tmp", env={"GONE_VAR": "x"})
|
result = await mgr.respawn_verified(
|
||||||
|
"hqt-gone", ["bash"], "/tmp", env={"GONE_VAR": "x"}
|
||||||
|
)
|
||||||
assert result.ok is True
|
assert result.ok is True
|
||||||
|
|
||||||
# The respawn-pane (step 3 of new_window) should carry the env
|
# The respawn-pane (step 3 of new_window) should carry the env
|
||||||
@@ -667,7 +721,12 @@ async def test_send_text_uses_literal_flag(runner):
|
|||||||
"""send_text must pass -l and '--' before the text."""
|
"""send_text must pass -l and '--' before the text."""
|
||||||
await runner.send_text("hqt-1", "Enter; echo hi C-c")
|
await runner.send_text("hqt-1", "Enter; echo hi C-c")
|
||||||
runner._exec.assert_called_once_with(
|
runner._exec.assert_called_once_with(
|
||||||
"send-keys", "-t", "hqt-main:=hqt-1", "-l", "--", "Enter; echo hi C-c",
|
"send-keys",
|
||||||
|
"-t",
|
||||||
|
"hqt-main:=hqt-1",
|
||||||
|
"-l",
|
||||||
|
"--",
|
||||||
|
"Enter; echo hi C-c",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -676,7 +735,10 @@ async def test_send_enter_no_literal_flag(runner):
|
|||||||
"""send_enter intentionally sends the Enter key name — must NOT have -l."""
|
"""send_enter intentionally sends the Enter key name — must NOT have -l."""
|
||||||
await runner.send_enter("hqt-1")
|
await runner.send_enter("hqt-1")
|
||||||
runner._exec.assert_called_once_with(
|
runner._exec.assert_called_once_with(
|
||||||
"send-keys", "-t", "hqt-main:=hqt-1", "Enter",
|
"send-keys",
|
||||||
|
"-t",
|
||||||
|
"hqt-main:=hqt-1",
|
||||||
|
"Enter",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -690,11 +752,16 @@ async def test_list_windows_info_alive_and_activity(runner):
|
|||||||
"""list_windows_info parses 3-column output; alive=True when pane_dead=0."""
|
"""list_windows_info parses 3-column output; alive=True when pane_dead=0."""
|
||||||
runner._exec.return_value = (0, "hqt-1\t0\t1000000\nhqt-2\t1\t999999\n", "")
|
runner._exec.return_value = (0, "hqt-1\t0\t1000000\nhqt-2\t1\t999999\n", "")
|
||||||
from hqt.tmux.runner import WindowInfo
|
from hqt.tmux.runner import WindowInfo
|
||||||
|
|
||||||
result = await runner.list_windows_info()
|
result = await runner.list_windows_info()
|
||||||
assert result["hqt-1"] == WindowInfo(alive=True, last_activity=1000000)
|
assert result["hqt-1"] == WindowInfo(alive=True, last_activity=1000000)
|
||||||
assert result["hqt-2"] == WindowInfo(alive=False, last_activity=999999)
|
assert result["hqt-2"] == WindowInfo(alive=False, last_activity=999999)
|
||||||
runner._exec.assert_called_once_with(
|
runner._exec.assert_called_once_with(
|
||||||
"list-panes", "-s", "-t", "hqt-main", "-F",
|
"list-panes",
|
||||||
|
"-s",
|
||||||
|
"-t",
|
||||||
|
"hqt-main",
|
||||||
|
"-F",
|
||||||
"#{window_name}\t#{pane_dead}\t#{window_activity}",
|
"#{window_name}\t#{pane_dead}\t#{window_activity}",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -708,6 +775,7 @@ async def test_list_windows_info_multi_pane_alive_max_activity(runner):
|
|||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
from hqt.tmux.runner import WindowInfo
|
from hqt.tmux.runner import WindowInfo
|
||||||
|
|
||||||
result = await runner.list_windows_info()
|
result = await runner.list_windows_info()
|
||||||
assert result["hqt-1"] == WindowInfo(alive=True, last_activity=2000)
|
assert result["hqt-1"] == WindowInfo(alive=True, last_activity=2000)
|
||||||
assert result["hqt-2"] == WindowInfo(alive=False, last_activity=600)
|
assert result["hqt-2"] == WindowInfo(alive=False, last_activity=600)
|
||||||
@@ -747,7 +815,12 @@ async def test_send_text_includes_double_dash(runner):
|
|||||||
"""send_text must pass '--' before the text to prevent flag parsing."""
|
"""send_text must pass '--' before the text to prevent flag parsing."""
|
||||||
await runner.send_text("hqt-1", "-flag-looking text")
|
await runner.send_text("hqt-1", "-flag-looking text")
|
||||||
runner._exec.assert_called_once_with(
|
runner._exec.assert_called_once_with(
|
||||||
"send-keys", "-t", "hqt-main:=hqt-1", "-l", "--", "-flag-looking text",
|
"send-keys",
|
||||||
|
"-t",
|
||||||
|
"hqt-main:=hqt-1",
|
||||||
|
"-l",
|
||||||
|
"--",
|
||||||
|
"-flag-looking text",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -756,7 +829,12 @@ async def test_send_text_double_dash_with_normal_text(runner):
|
|||||||
"""send_text includes '--' even for normal text (belt-and-suspenders)."""
|
"""send_text includes '--' even for normal text (belt-and-suspenders)."""
|
||||||
await runner.send_text("hqt-1", "hello world")
|
await runner.send_text("hqt-1", "hello world")
|
||||||
runner._exec.assert_called_once_with(
|
runner._exec.assert_called_once_with(
|
||||||
"send-keys", "-t", "hqt-main:=hqt-1", "-l", "--", "hello world",
|
"send-keys",
|
||||||
|
"-t",
|
||||||
|
"hqt-main:=hqt-1",
|
||||||
|
"-l",
|
||||||
|
"--",
|
||||||
|
"hello world",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -765,6 +843,7 @@ async def test_poll_info_single_exec(runner):
|
|||||||
"""poll_info calls _exec exactly once and returns WindowInfo per name."""
|
"""poll_info calls _exec exactly once and returns WindowInfo per name."""
|
||||||
runner._exec.return_value = (0, "hqt-1\t0\t1234\nhqt-2\t1\t5678\n", "")
|
runner._exec.return_value = (0, "hqt-1\t0\t1234\nhqt-2\t1\t5678\n", "")
|
||||||
from hqt.tmux.runner import WindowInfo
|
from hqt.tmux.runner import WindowInfo
|
||||||
|
|
||||||
mgr = TmuxManager(runner)
|
mgr = TmuxManager(runner)
|
||||||
result = await mgr.poll_info(["hqt-1", "hqt-2", "hqt-missing"])
|
result = await mgr.poll_info(["hqt-1", "hqt-2", "hqt-missing"])
|
||||||
assert runner._exec.call_count == 1
|
assert runner._exec.call_count == 1
|
||||||
@@ -881,7 +960,8 @@ async def test_apply_theme_paints_window_options_on_every_window(runner):
|
|||||||
for window in ("⌂ HQT", "hqt-1", "hqt-2"):
|
for window in ("⌂ HQT", "hqt-1", "hqt-2"):
|
||||||
target = f"hqt-main:={window}"
|
target = f"hqt-main:={window}"
|
||||||
idxs = [
|
idxs = [
|
||||||
i for i, a in enumerate(args)
|
i
|
||||||
|
for i, a in enumerate(args)
|
||||||
if a == "window-status-current-style" and args[i - 1] == target
|
if a == "window-status-current-style" and args[i - 1] == target
|
||||||
]
|
]
|
||||||
assert idxs, f"no current-style set for {window}"
|
assert idxs, f"no current-style set for {window}"
|
||||||
|
|||||||
+91
-36
@@ -17,7 +17,7 @@ def mock_services(tmp_path, monkeypatch):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_app_mounts():
|
async def test_app_mounts():
|
||||||
app = HqtApp()
|
app = HqtApp()
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test():
|
||||||
assert app.title == "HQ Terminal"
|
assert app.title == "HQ Terminal"
|
||||||
|
|
||||||
|
|
||||||
@@ -27,16 +27,17 @@ async def test_no_header_bar():
|
|||||||
from textual.widgets import Header
|
from textual.widgets import Header
|
||||||
|
|
||||||
app = HqtApp()
|
app = HqtApp()
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test():
|
||||||
assert len(app.query(Header)) == 0
|
assert len(app.query(Header)) == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_app_has_project_and_session_panels():
|
async def test_app_has_project_and_session_panels():
|
||||||
app = HqtApp()
|
app = HqtApp()
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test():
|
||||||
from hqt.tui.widgets.project_list import ProjectList
|
from hqt.tui.widgets.project_list import ProjectList
|
||||||
from hqt.tui.widgets.session_list import SessionList
|
from hqt.tui.widgets.session_list import SessionList
|
||||||
|
|
||||||
assert app.query_one(ProjectList) is not None
|
assert app.query_one(ProjectList) is not None
|
||||||
assert app.query_one(SessionList) is not None
|
assert app.query_one(SessionList) is not None
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ async def test_app_has_project_and_session_panels():
|
|||||||
async def test_session_refresh_no_duplicate_ids():
|
async def test_session_refresh_no_duplicate_ids():
|
||||||
"""Regression test: rapid session refreshes must not crash with DuplicateIds."""
|
"""Regression test: rapid session refreshes must not crash with DuplicateIds."""
|
||||||
app = HqtApp()
|
app = HqtApp()
|
||||||
async with app.run_test(size=(120, 40)) as pilot:
|
async with app.run_test(size=(120, 40)):
|
||||||
from hqt.db.models import Project, Harness, Session
|
from hqt.db.models import Project, Harness, Session
|
||||||
|
|
||||||
proj = Project(name="test", path="/tmp/test")
|
proj = Project(name="test", path="/tmp/test")
|
||||||
@@ -116,9 +117,12 @@ async def test_session_selection_preserved_across_refresh():
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _make_session_info(session_id: int, name: str, harness_name: str, status: str, alive: bool):
|
def _make_session_info(
|
||||||
|
session_id: int, name: str, harness_name: str, status: str, alive: bool
|
||||||
|
):
|
||||||
"""Build a minimal SessionInfo-like object for widget testing."""
|
"""Build a minimal SessionInfo-like object for widget testing."""
|
||||||
from hqt.sessions.service import SessionInfo
|
from hqt.sessions.service import SessionInfo
|
||||||
|
|
||||||
harness = MagicMock()
|
harness = MagicMock()
|
||||||
harness.name = harness_name
|
harness.name = harness_name
|
||||||
session = MagicMock()
|
session = MagicMock()
|
||||||
@@ -150,13 +154,23 @@ async def test_session_list_label_includes_status():
|
|||||||
label_texts = [str(child.query_one(Label).render()) for child in lv.children]
|
label_texts = [str(child.query_one(Label).render()) for child in lv.children]
|
||||||
|
|
||||||
# Every label must contain the status word
|
# Every label must contain the status word
|
||||||
assert any("working" in t for t in label_texts), f"Expected 'working' in labels: {label_texts}"
|
assert any("working" in t for t in label_texts), (
|
||||||
assert any("idle" in t for t in label_texts), f"Expected 'idle' in labels: {label_texts}"
|
f"Expected 'working' in labels: {label_texts}"
|
||||||
assert any("dead" in t for t in label_texts), f"Expected 'dead' in labels: {label_texts}"
|
)
|
||||||
|
assert any("idle" in t for t in label_texts), (
|
||||||
|
f"Expected 'idle' in labels: {label_texts}"
|
||||||
|
)
|
||||||
|
assert any("dead" in t for t in label_texts), (
|
||||||
|
f"Expected 'dead' in labels: {label_texts}"
|
||||||
|
)
|
||||||
|
|
||||||
# Symbols: working → ◐, idle → ●, dead → ○
|
# Symbols: working → ◐, idle → ●, dead → ○
|
||||||
assert any("◐" in t for t in label_texts), f"Expected '◐' in labels: {label_texts}"
|
assert any("◐" in t for t in label_texts), (
|
||||||
assert any("○" in t for t in label_texts), f"Expected '○' in labels: {label_texts}"
|
f"Expected '◐' in labels: {label_texts}"
|
||||||
|
)
|
||||||
|
assert any("○" in t for t in label_texts), (
|
||||||
|
f"Expected '○' in labels: {label_texts}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -270,7 +284,10 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh():
|
|||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
call_order.append("create")
|
call_order.append("create")
|
||||||
from hqt.sessions.service import CreateSessionResult
|
from hqt.sessions.service import CreateSessionResult
|
||||||
return CreateSessionResult(session=MagicMock(), spawn_ok=True, spawn_error="")
|
|
||||||
|
return CreateSessionResult(
|
||||||
|
session=MagicMock(), spawn_ok=True, spawn_error=""
|
||||||
|
)
|
||||||
|
|
||||||
async def fake_list(project_id):
|
async def fake_list(project_id):
|
||||||
call_order.append("list")
|
call_order.append("list")
|
||||||
@@ -284,6 +301,7 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh():
|
|||||||
|
|
||||||
# Patch discover_harnesses so the early-return guard doesn't fire.
|
# Patch discover_harnesses so the early-return guard doesn't fire.
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
with patch("hqt.tui.app.discover_harnesses", return_value={"claude": object()}):
|
with patch("hqt.tui.app.discover_harnesses", return_value={"claude": object()}):
|
||||||
app.action_new_session()
|
app.action_new_session()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
@@ -296,7 +314,9 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh():
|
|||||||
await app.workers.wait_for_complete()
|
await app.workers.wait_for_complete()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
assert call_order == ["create", "list"], f"Expected create before list, got: {call_order}"
|
assert call_order == ["create", "list"], (
|
||||||
|
f"Expected create before list, got: {call_order}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -360,11 +380,17 @@ async def test_new_session_on_dismiss_notifies_on_spawn_failure():
|
|||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
# Should have notified with severity="error"
|
# Should have notified with severity="error"
|
||||||
error_notifs = [(m, kw) for m, kw in notify_calls if kw.get("severity") == "error"]
|
error_notifs = [
|
||||||
assert len(error_notifs) >= 1, f"Expected error notification, got: {notify_calls}"
|
(m, kw) for m, kw in notify_calls if kw.get("severity") == "error"
|
||||||
|
]
|
||||||
|
assert len(error_notifs) >= 1, (
|
||||||
|
f"Expected error notification, got: {notify_calls}"
|
||||||
|
)
|
||||||
# Message should contain the first line of the error
|
# Message should contain the first line of the error
|
||||||
msg_text = error_notifs[0][0]
|
msg_text = error_notifs[0][0]
|
||||||
assert "command not found" in msg_text or "claude" in msg_text, f"Unexpected message: {msg_text}"
|
assert "command not found" in msg_text or "claude" in msg_text, (
|
||||||
|
f"Unexpected message: {msg_text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -394,7 +420,9 @@ async def test_new_session_on_dismiss_no_notify_on_success():
|
|||||||
app._db_session.add(fake_sess)
|
app._db_session.add(fake_sess)
|
||||||
app._db_session.flush()
|
app._db_session.flush()
|
||||||
|
|
||||||
ok_result = CreateSessionResult(session=fake_sess, spawn_ok=True, spawn_error="")
|
ok_result = CreateSessionResult(
|
||||||
|
session=fake_sess, spawn_ok=True, spawn_error=""
|
||||||
|
)
|
||||||
|
|
||||||
mock_service = MagicMock()
|
mock_service = MagicMock()
|
||||||
mock_service.create_session = AsyncMock(return_value=ok_result)
|
mock_service.create_session = AsyncMock(return_value=ok_result)
|
||||||
@@ -417,8 +445,12 @@ async def test_new_session_on_dismiss_no_notify_on_success():
|
|||||||
await app.workers.wait_for_complete()
|
await app.workers.wait_for_complete()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
error_notifs = [(m, kw) for m, kw in notify_calls if kw.get("severity") == "error"]
|
error_notifs = [
|
||||||
assert len(error_notifs) == 0, f"Expected no error notification, got: {error_notifs}"
|
(m, kw) for m, kw in notify_calls if kw.get("severity") == "error"
|
||||||
|
]
|
||||||
|
assert len(error_notifs) == 0, (
|
||||||
|
f"Expected no error notification, got: {error_notifs}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -609,9 +641,9 @@ async def test_edit_project_no_selection_warns():
|
|||||||
await app.run_action("edit_project")
|
await app.run_action("edit_project")
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
assert any(
|
assert any(kw.get("severity") == "warning" for _, kw in notify_calls), (
|
||||||
kw.get("severity") == "warning" for _, kw in notify_calls
|
f"Expected warning notification, got: {notify_calls}"
|
||||||
), f"Expected warning notification, got: {notify_calls}"
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -694,7 +726,7 @@ async def test_edit_project_duplicate_path_notifies_error():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_frappe_theme_fully_applied():
|
async def test_frappe_theme_fully_applied():
|
||||||
app = HqtApp()
|
app = HqtApp()
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test():
|
||||||
assert app.current_theme.name == "catppuccin-frappe"
|
assert app.current_theme.name == "catppuccin-frappe"
|
||||||
variables = app.get_css_variables()
|
variables = app.get_css_variables()
|
||||||
# Spot-check: explicit values, not Textual auto-derivations
|
# Spot-check: explicit values, not Textual auto-derivations
|
||||||
@@ -809,10 +841,14 @@ async def test_dialog_button_focus_is_visibly_highlighted():
|
|||||||
# $accent (Frappé Peach #ef9f76) fill — peach is red-dominant, so the
|
# $accent (Frappé Peach #ef9f76) fill — peach is red-dominant, so the
|
||||||
# focused button is unmistakably distinct from the blue/ghost defaults.
|
# focused button is unmistakably distinct from the blue/ghost defaults.
|
||||||
# (Textual adds a small focus background-tint, so allow ±2 per channel.)
|
# (Textual adds a small focus background-tint, so allow ±2 per channel.)
|
||||||
assert abs(bg.r - 0xEF) <= 2 and abs(bg.g - 0x9F) <= 2 and abs(bg.b - 0x76) <= 2, (
|
assert (
|
||||||
f"{btn_id} focus background {bg.hex}, expected ~Peach #EF9F76"
|
abs(bg.r - 0xEF) <= 2
|
||||||
|
and abs(bg.g - 0x9F) <= 2
|
||||||
|
and abs(bg.b - 0x76) <= 2
|
||||||
|
), f"{btn_id} focus background {bg.hex}, expected ~Peach #EF9F76"
|
||||||
|
assert bg.r > bg.b, (
|
||||||
|
f"{btn_id} focus highlight should be peach, not blue ({bg.hex})"
|
||||||
)
|
)
|
||||||
assert bg.r > bg.b, f"{btn_id} focus highlight should be peach, not blue ({bg.hex})"
|
|
||||||
assert btn.styles.color.hex == "#292C3C", (
|
assert btn.styles.color.hex == "#292C3C", (
|
||||||
f"{btn_id} focus text color {btn.styles.color.hex}, expected dark"
|
f"{btn_id} focus text color {btn.styles.color.hex}, expected dark"
|
||||||
)
|
)
|
||||||
@@ -837,7 +873,9 @@ async def test_first_project_selected_on_load():
|
|||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
pl = app.query_one(ProjectList)
|
pl = app.query_one(ProjectList)
|
||||||
first_id = pl.query_one("#project-list").children[0].data
|
first_item_id = pl.query_one("#project-list").children[0].id
|
||||||
|
assert first_item_id is not None
|
||||||
|
first_id = int(first_item_id.removeprefix("proj-"))
|
||||||
assert pl.get_selected_project_id() == first_id
|
assert pl.get_selected_project_id() == first_id
|
||||||
assert app._selected_project_id == first_id
|
assert app._selected_project_id == first_id
|
||||||
|
|
||||||
@@ -857,9 +895,24 @@ async def test_first_session_selected_and_remembered_per_project():
|
|||||||
pb = Project(name="B", path="/tmp/proj-B")
|
pb = Project(name="B", path="/tmp/proj-B")
|
||||||
app._db_session.add_all([pa, pb])
|
app._db_session.add_all([pa, pb])
|
||||||
app._db_session.flush()
|
app._db_session.flush()
|
||||||
a1 = Session(project_id=pa.id, harness_id=h.id, tmux_session_name="hqt-a1", archived=False)
|
a1 = Session(
|
||||||
a2 = Session(project_id=pa.id, harness_id=h.id, tmux_session_name="hqt-a2", archived=False)
|
project_id=pa.id,
|
||||||
b1 = Session(project_id=pb.id, harness_id=h.id, tmux_session_name="hqt-b1", archived=False)
|
harness_id=h.id,
|
||||||
|
tmux_session_name="hqt-a1",
|
||||||
|
archived=False,
|
||||||
|
)
|
||||||
|
a2 = Session(
|
||||||
|
project_id=pa.id,
|
||||||
|
harness_id=h.id,
|
||||||
|
tmux_session_name="hqt-a2",
|
||||||
|
archived=False,
|
||||||
|
)
|
||||||
|
b1 = Session(
|
||||||
|
project_id=pb.id,
|
||||||
|
harness_id=h.id,
|
||||||
|
tmux_session_name="hqt-b1",
|
||||||
|
archived=False,
|
||||||
|
)
|
||||||
app._db_session.add_all([a1, a2, b1])
|
app._db_session.add_all([a1, a2, b1])
|
||||||
app._db_session.commit()
|
app._db_session.commit()
|
||||||
|
|
||||||
@@ -908,10 +961,14 @@ def test_format_session_text_colors_and_escapes():
|
|||||||
def test_format_session_text_status_colors():
|
def test_format_session_text_status_colors():
|
||||||
from hqt.tui.widgets.session_list import format_session_text
|
from hqt.tui.widgets.session_list import format_session_text
|
||||||
|
|
||||||
assert format_session_text("x", "h", "waiting", "◉").startswith("[#e5c890]") # Yellow
|
assert format_session_text("x", "h", "waiting", "◉").startswith(
|
||||||
|
"[#e5c890]"
|
||||||
|
) # Yellow
|
||||||
assert format_session_text("x", "h", "idle", "●").startswith("[#81c8be]") # Teal
|
assert format_session_text("x", "h", "idle", "●").startswith("[#81c8be]") # Teal
|
||||||
assert format_session_text("x", "h", "active", "●").startswith("[#81c8be]") # Teal
|
assert format_session_text("x", "h", "active", "●").startswith("[#81c8be]") # Teal
|
||||||
assert format_session_text("x", "h", "dead", "○").startswith("[#737994]") # Overlay0
|
assert format_session_text("x", "h", "dead", "○").startswith(
|
||||||
|
"[#737994]"
|
||||||
|
) # Overlay0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -1005,9 +1062,9 @@ async def test_rename_session_no_selection_warns():
|
|||||||
await app.run_action("rename_session")
|
await app.run_action("rename_session")
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
assert any(
|
assert any(kw.get("severity") == "warning" for _, kw in notify_calls), (
|
||||||
kw.get("severity") == "warning" for _, kw in notify_calls
|
f"Expected warning notification, got: {notify_calls}"
|
||||||
), f"Expected warning notification, got: {notify_calls}"
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -1128,5 +1185,3 @@ async def test_session_list_jk_navigation():
|
|||||||
await pilot.press("k")
|
await pilot.press("k")
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
assert sl.get_selected_session_id() == sessions[0].id
|
assert sl.get_selected_session_id() == sessions[0].id
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user