tmux-amq/src/tamq/cli.py

353 lines
17 KiB
Python
Raw Normal View History

"""Initial tamq command-line surface."""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import signal
import fcntl
from uuid import uuid4
import subprocess
import sys
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from . import __version__
from .config import db_path, pid_path, lock_path, config_path, socket_path, state_dir, setting
from .service import Service, ping, request
from .store import Store
from .tmux import TmuxError, TmuxManager
from .broker import BrokerIdentity, InputBroker
from .ptytap import PtyTap
from .control import ControlModeClient
from .diagnostics import configure
from .policy import load_profile
def parse_size(value: str) -> int:
units = {"B": 1, "KB": 1000, "MB": 1000**2, "GB": 1000**3}
text = value.upper()
for unit, multiplier in sorted(units.items(), key=lambda item: -len(item[0])):
if text.endswith(unit):
return int(float(text[:-len(unit)]) * multiplier)
return int(text)
def history_advisory(store: Store) -> str | None:
size, oldest = store.history_stats()
limit_text = setting("history_max_size", "100MB")
limit = parse_size(limit_text)
if size <= limit:
return None
since = time.strftime("%y%m%d", time.localtime(oldest)) if oldest else "unknown"
return f"Size of history is {size / 1000**2:.0f} MB since {since}, exceeding {limit_text}. Consider using 'tamq purge'."
def ensure_service() -> bool:
if asyncio.run(ping()):
return True
path = lock_path()
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a+") as lock:
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
if asyncio.run(ping()):
return True
subprocess.Popen(
[sys.executable, "-m", "tamq.cli", "serve"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
env=os.environ.copy(),
)
for _ in range(20):
if asyncio.run(ping()):
return True
time.sleep(0.05)
return False
def preflight_runtime_paths() -> None:
"""Ensure local state/runtime parents are available before tmux mutation."""
parents = {db_path().parent, socket_path().parent, pid_path().parent, lock_path().parent}
for parent in parents:
parent.mkdir(parents=True, exist_ok=True)
if not os.access(parent, os.W_OK | os.X_OK):
raise OSError(f"runtime directory is not writable: {parent}")
def attach_session(session: str) -> int:
command = ["tmux", "switch-client" if os.environ.get("TMUX") else "attach-session", "-t", session]
return subprocess.run(command, check=False).returncode
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="tamq",
description="Tmux Agentic Message Queueing for local repository workers.",
)
parser.add_argument("--version", "-V", action="version", version=__version__)
parser.add_argument("--orwell", action="store_true", help="enable unsafe local diagnostics")
parser.add_argument("--verbose", action="store_true", help="enable debug diagnostics")
parser.add_argument("--policy-profile", default=None, help="select configured policy profile")
subparsers = parser.add_subparsers(dest="command")
start = subparsers.add_parser("start", help="start or reuse a tamq endpoint")
start.add_argument("repos", nargs="*", help="gita-registered repository slugs")
start.add_argument("--command", "--cmd", dest="agent_command", default="codex", help="agent command for new windows (default: codex)")
start.add_argument("--detach", action="store_true", help="detach after startup")
start.add_argument("--no-service", action="store_true", help="open tmux windows without service registration or messaging")
attach = subparsers.add_parser("attach", help="attach to the managed tmux session")
attach.add_argument("--session", default="tamq", help="tmux session name")
subparsers.add_parser("serve", help="run the local service in the foreground")
subparsers.add_parser("stop", help="stop the local service")
subparsers.add_parser("status", help="show endpoint health")
subparsers.add_parser("ping", help="check local service liveness")
history = subparsers.add_parser("history", help="inspect local message history")
history.add_argument("--repo", dest="target_repo")
history.add_argument("--state")
inspect = subparsers.add_parser("inspect", help="inspect one message")
inspect.add_argument("message_id")
ack = subparsers.add_parser("ack", help="acknowledge one injected message")
ack.add_argument("message_id")
send = subparsers.add_parser("send", help="queue a direct message")
send.add_argument("address", help="@repo: message")
send.add_argument("body", nargs="*", help="message body when address is a repo slug")
send.add_argument("--endpoint-id")
export = subparsers.add_parser("export", help="export history as JSONL")
export.add_argument("--output", required=True)
export.add_argument("--repo", dest="target_repo")
export.add_argument("--state")
replay = subparsers.add_parser("replay", help="replay a JSONL message file")
replay.add_argument("file")
replay.add_argument("--endpoint-id", help="target a registered tmux-amq endpoint")
purge = subparsers.add_parser("purge", help="dry-run or remove history")
purge.add_argument("--before", default=None)
purge.add_argument("--max-size", default=None)
purge.add_argument("--yes", action="store_true")
purge.add_argument("--orwell", action="store_true")
completion = subparsers.add_parser("completion", help="emit shell completion script")
completion.add_argument("shell", choices=("bash", "zsh", "fish"))
subparsers.add_parser("db-version", help="show SQLite schema version")
subparsers.add_parser("config", help="show effective configuration paths and policy")
tap = subparsers.add_parser("tap", help="run an agent behind the full-duplex PTY tap")
tap.add_argument("--repo", required=True, help="source gita repository slug")
tap.add_argument("--endpoint", required=True, help="tmux-amq endpoint identity")
tap.add_argument("agent_command", nargs=argparse.REMAINDER, help="agent command after --")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
configure(args.orwell, args.verbose)
try:
profile = load_profile(selected=args.policy_profile or os.environ.get("TAMQ_POLICY_PROFILE", "default"))
except ValueError as exc:
print(f"tamq: {exc}", file=sys.stderr)
return 2
if args.command is None:
parser.print_help()
return 0
if args.command == "db-version":
store = Store(db_path()); print(store.db.execute("SELECT value FROM metadata WHERE key='schema_version'").fetchone()[0]); store.close(); return 0
if args.command == "ping":
return 0 if asyncio.run(ping()) else 1
if args.command == "stop":
try:
pid = int(pid_path().read_text(encoding="utf-8"))
os.kill(pid, signal.SIGTERM)
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
try:
os.kill(pid, 0)
except ProcessLookupError:
break
time.sleep(0.05)
print(f"stopped tamq service {pid}")
return 0
except ProcessLookupError:
pid_path().unlink(missing_ok=True)
print("tamq service is not running", file=sys.stderr)
return 1
except (FileNotFoundError, ValueError):
print("tamq service is not running", file=sys.stderr)
return 1
if args.command == "serve":
try: asyncio.run(Service().run())
except KeyboardInterrupt: return 0
return 0
if args.command == "attach":
return attach_session(args.session)
if args.command == "completion":
print(completion_script(args.shell), end="")
return 0
if args.command == "config":
print(json.dumps({"config": str(config_path()), "state_dir": str(state_dir()), "database": str(db_path()), "socket": str(socket_path()), "pidfile": str(pid_path()), "lockfile": str(lock_path()), "policy_profile": profile.name, "delivery_ack_mode": profile.delivery_ack_mode}, sort_keys=True))
return 0
if args.command == "tap":
command = list(args.agent_command)
if command and command[0] == "--":
command = command[1:]
if not command:
print("tamq tap requires an agent command after --", file=sys.stderr)
return 2
store = Store(db_path())
try:
return PtyTap(command, InputBroker(store, BrokerIdentity(args.endpoint, args.repo))).run()
finally:
store.close()
if args.command == "start":
manager = TmuxManager()
try:
preflight_runtime_paths()
launch_plan = manager.preflight(args.repos, args.agent_command)
except (TmuxError, OSError) as exc:
print(f"tamq: {exc}", file=sys.stderr)
return 2
if not args.no_service and not ensure_service():
print("tamq service failed to start; use --no-service to open repos without messaging", file=sys.stderr)
return 1
try:
endpoint = manager.ensure_plan(launch_plan)
except (TmuxError, OSError) as exc:
print(f"tamq: {exc}", file=sys.stderr)
return 2
delivered = 0
registered = False
if not args.no_service:
try:
registration = asyncio.run(request({"op": "register", "endpoint_id": endpoint.endpoint_id, "instance_id": endpoint.instance_key, "pid": endpoint.pid, "session": endpoint.session, "repos": endpoint.repos}))
if not registration.get("ok"):
raise RuntimeError(f"endpoint registration failed: {registration.get('error', 'unknown error')}")
registered = True
control = ControlModeClient(endpoint.session)
queue_store = Store(db_path())
try:
control.start()
delivered = InputBroker(queue_store, BrokerIdentity(endpoint.instance_key, endpoint.repos[0])).deliver_pending(
control, window_for_repo=lambda repo: f"{endpoint.session}:{repo}"
)
finally:
control.close()
queue_store.close()
except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc:
manager.rollback(endpoint)
print(f"tamq: {exc}", file=sys.stderr)
return 1
summary = {
"endpoint_id": endpoint.endpoint_id,
"instance_id": endpoint.instance_key,
"session": endpoint.session,
"repos": endpoint.repos,
"delivered": delivered,
"service": not args.no_service,
"messaging": registered,
}
print(json.dumps(summary), flush=True)
if args.detach:
return 0
return attach_session(endpoint.session)
store = Store(db_path())
try:
advisory = history_advisory(store)
if advisory and args.command in {"start", "serve", "status", "history", "send"}:
print(advisory, file=sys.stderr)
if args.command == "send":
text = " ".join([args.address, *args.body]).strip()
if not text.startswith("@") or ":" not in text:
print("tamq send expects @repo: message", file=sys.stderr); return 2
target, body = text[1:].split(":", 1); body = body.strip()
if not target or not body: print("tamq send requires a target and body", file=sys.stderr); return 2
if args.endpoint_id:
if not asyncio.run(ping()):
print("tamq: service is not running", file=sys.stderr); return 1
response = asyncio.run(request({"op": "send", "endpoint_id": args.endpoint_id, "sender_repo": "local", "target_repo": target, "body": body}))
if not response.get("ok"):
print(f"tamq: {response.get('error', 'send failed')}", file=sys.stderr); return 1
print(response["message_id"]); return 0
try:
print(store.add("local", target, body))
except ValueError as exc:
print(f"tamq: {exc}", file=sys.stderr); return 2
return 0
if args.command == "history":
for row in store.list(args.target_repo, args.state): print(json.dumps(dict(row), sort_keys=True))
return 0
if args.command == "inspect":
rows = [row for row in store.list() if row["message_id"] == args.message_id]
if not rows:
print("tamq: message not found", file=sys.stderr)
return 1
print(json.dumps(dict(rows[0]), sort_keys=True)); return 0
if args.command == "ack":
if not store.acknowledge(args.message_id):
print("tamq: message not found", file=sys.stderr); return 1
print(args.message_id); return 0
if args.command == "export":
store.export(store.list(args.target_repo, args.state), Path(args.output)); return 0
if args.command == "replay":
batch_id = f"replay-{uuid4()}"
count = 0
if args.endpoint_id and store.endpoint(args.endpoint_id) is None:
print(f"tamq: endpoint is not registered: {args.endpoint_id}", file=sys.stderr)
return 1
try:
lines = Path(args.file).read_text(encoding="utf-8").splitlines()
for line in lines:
if not line.strip():
continue
item = json.loads(line)
provenance = json.dumps({"batch_id": batch_id, "original_message_id": item.get("message_id")}, sort_keys=True)
endpoint = args.endpoint_id or item.get("endpoint_id")
store.add(item.get("sender_repo", "replay"), item["target_repo"], item["body"], endpoint=endpoint, provenance=provenance)
count += 1
except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
print(f"tamq: cannot replay {args.file}: {exc}", file=sys.stderr)
return 2
print(json.dumps({"batch_id": batch_id, "count": count})); return 0
if args.command == "purge":
before_text = args.before or setting("purge_before", "365d")
max_size_text = args.max_size or setting("purge_max_size", "100MB")
before = time.time() - int(before_text[:-1]) * 86400 if before_text.endswith("d") else None
if not args.yes:
matching = [row for row in store.list() if before is None or row["created_at"] < before]
print(f"Dry run: {len(matching)} message(s) match; re-run with --yes to purge.")
return 0
count = store.purge(before=before, max_bytes=parse_size(max_size_text))
print(f"Purged {count} message(s)."); return 0
if args.command == "status":
live = asyncio.run(ping())
endpoints = asyncio.run(request({"op": "endpoints"})) if live else {"endpoints": []}
print(json.dumps({"version": __version__, "db": str(db_path()), "messages": len(store.list()), "pending": len(store.list(state="pending")), "leases": store.db.execute("SELECT COUNT(*) FROM leases").fetchone()[0], "service": live, "policy_profile": profile.name, "delivery_ack_mode": profile.delivery_ack_mode, "endpoints": endpoints.get("endpoints", [])}, sort_keys=True)); return 0
print(f"tamq {__version__}: command '{args.command}' is not implemented yet", file=sys.stderr)
return 2
finally:
store.close()
def completion_script(shell: str) -> str:
commands = "start attach serve stop status ping history inspect ack send export replay purge completion db-version config tap"
if shell == "bash":
return f"""_tamq_complete() {{
local commands=\"{commands}\"
if [[ ${{COMP_WORDS[1]}} == start || ${{COMP_WORDS[1]}} == send ]]; then
COMPREPLY=( $(compgen -W \"$(gita ls 2>/dev/null)\" -- \"${{COMP_WORDS[COMP_CWORD]}}\") )
else
COMPREPLY=( $(compgen -W \"$commands\" -- \"${{COMP_WORDS[COMP_CWORD]}}\") )
fi
}}
complete -F _tamq_complete tamq
"""
if shell == "zsh":
return f"#compdef tamq\n_arguments '1:command:(({commands}))' '*:repo:->repos'\nif [[ $state == repos ]]; then _values 'gita repo' $(gita ls 2>/dev/null); fi\n"
return f"complete -c tamq -f -n '__fish_seen_subcommand_from start send' -a '(gita ls 2>/dev/null)'\ncomplete -c tamq -f -a '{commands}'\n"
if __name__ == "__main__":
raise SystemExit(main())