91e75e620b
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
344 lines
11 KiB
Python
344 lines
11 KiB
Python
"""cua trajectory — manage recorded action trajectories.
|
|
|
|
Subcommands:
|
|
ls List trajectory sessions
|
|
view Zip + serve locally, open cua.ai/trajectory-viewer
|
|
clean Delete old sessions
|
|
stop Stop the local file server
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import webbrowser
|
|
from pathlib import Path
|
|
from urllib.parse import quote
|
|
|
|
from cua_cli.utils.trajectory_recorder import (
|
|
clean_trajectories,
|
|
list_trajectories,
|
|
zip_trajectory,
|
|
)
|
|
|
|
_PID_FILE = Path.home() / ".cua" / "trajectory_server.pid"
|
|
|
|
|
|
def register_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
"""Register ``cua trajectory`` (and alias ``cua traj``) commands."""
|
|
for cmd_name in ("trajectory", "traj"):
|
|
p = subparsers.add_parser(
|
|
cmd_name,
|
|
help="Manage recorded action trajectories",
|
|
description="List, view, and clean trajectory recordings from cua do sessions.",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
cua trajectory ls List all sessions
|
|
cua trajectory ls my-container List sessions for a machine
|
|
cua trajectory view View latest session in browser
|
|
cua trajectory view my-container View latest for specific machine
|
|
cua trajectory view --port 9090 Use a custom port
|
|
cua trajectory stop Stop the file server
|
|
cua trajectory clean --older-than 7 Delete sessions older than 7 days
|
|
cua trajectory clean --machine my-ct -y Delete all sessions for a machine
|
|
""",
|
|
)
|
|
|
|
sub = p.add_subparsers(dest="traj_action", metavar="action")
|
|
sub.required = True
|
|
|
|
# ls
|
|
ls_p = sub.add_parser("ls", help="List trajectory sessions")
|
|
ls_p.add_argument("machine", nargs="?", default=None, help="Filter by machine name")
|
|
ls_p.add_argument("--json", dest="as_json", action="store_true", help="Output as JSON")
|
|
|
|
# view
|
|
view_p = sub.add_parser("view", help="Zip and open in cua.ai/trajectory-viewer")
|
|
view_p.add_argument(
|
|
"target",
|
|
nargs="?",
|
|
default=None,
|
|
help="Machine name, session timestamp, or path (default: latest session)",
|
|
)
|
|
view_p.add_argument(
|
|
"--port",
|
|
"-p",
|
|
type=int,
|
|
default=8089,
|
|
help="Port for the local file server (default: 8089)",
|
|
)
|
|
|
|
# clean
|
|
clean_p = sub.add_parser("clean", help="Delete old trajectory sessions")
|
|
clean_p.add_argument(
|
|
"--older-than",
|
|
type=int,
|
|
default=None,
|
|
metavar="DAYS",
|
|
help="Only delete sessions older than DAYS days",
|
|
)
|
|
clean_p.add_argument(
|
|
"--machine", default=None, help="Only delete sessions for this machine"
|
|
)
|
|
clean_p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation prompt")
|
|
|
|
# stop
|
|
sub.add_parser("stop", help="Stop the trajectory file server")
|
|
|
|
|
|
# ── ls ───────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _cmd_ls(args: argparse.Namespace) -> int:
|
|
machine = getattr(args, "machine", None)
|
|
sessions = list_trajectories(machine=machine)
|
|
|
|
if not sessions:
|
|
if machine:
|
|
print(f"No trajectory sessions found for '{machine}'.")
|
|
else:
|
|
print("No trajectory sessions found.")
|
|
return 0
|
|
|
|
if getattr(args, "as_json", False):
|
|
print(json.dumps(sessions, indent=2))
|
|
return 0
|
|
|
|
print(f"{'Machine':<20} {'Session':<18} {'Turns':>5} {'Created'}")
|
|
print("-" * 70)
|
|
for s in sessions:
|
|
print(f"{s['machine']:<20} {s['session']:<18} {s['turns']:>5} {s['created']}")
|
|
return 0
|
|
|
|
|
|
# ── view ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _resolve_session(target: str | None) -> str | None:
|
|
"""Resolve a target to a session path."""
|
|
if target and Path(target).is_dir():
|
|
return target
|
|
|
|
sessions = list_trajectories()
|
|
if not sessions:
|
|
return None
|
|
|
|
if target is None:
|
|
return sessions[-1]["path"]
|
|
|
|
# Try as machine name (latest for that machine)
|
|
machine_sessions = [s for s in sessions if s["machine"] == target]
|
|
if machine_sessions:
|
|
return machine_sessions[-1]["path"]
|
|
|
|
# Try as session timestamp
|
|
for s in sessions:
|
|
if s["session"] == target:
|
|
return s["path"]
|
|
|
|
return None
|
|
|
|
|
|
# Minimal CORS-enabled HTTP server script, run as a subprocess.
|
|
_SERVER_SCRIPT = """\
|
|
import sys, os
|
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
|
|
|
class CORSHandler(SimpleHTTPRequestHandler):
|
|
def end_headers(self):
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
self.send_header("Access-Control-Allow-Headers", "*")
|
|
super().end_headers()
|
|
def do_OPTIONS(self):
|
|
self.send_response(204)
|
|
self.end_headers()
|
|
def log_message(self, format, *args):
|
|
pass
|
|
|
|
os.chdir(sys.argv[1])
|
|
port = int(sys.argv[2])
|
|
HTTPServer(("127.0.0.1", port), CORSHandler).serve_forever()
|
|
"""
|
|
|
|
|
|
def _cmd_view(args: argparse.Namespace) -> int:
|
|
target = getattr(args, "target", None)
|
|
port = getattr(args, "port", 8089)
|
|
session_path = _resolve_session(target)
|
|
|
|
if not session_path:
|
|
label = f" for '{target}'" if target else ""
|
|
print(f"No trajectory session found{label}.", file=sys.stderr)
|
|
return 1
|
|
|
|
session_dir = Path(session_path)
|
|
|
|
# Zip the session
|
|
zip_path = zip_trajectory(session_dir)
|
|
zip_name = zip_path.name
|
|
|
|
# Stop any existing server
|
|
_stop_server(quiet=True)
|
|
|
|
# Start a CORS-enabled file server in the background
|
|
serve_dir = str(zip_path.parent)
|
|
proc = subprocess.Popen(
|
|
[sys.executable, "-c", _SERVER_SCRIPT, serve_dir, str(port)],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
|
|
# Save PID
|
|
_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
_PID_FILE.write_text(json.dumps({"pid": proc.pid, "port": port}))
|
|
|
|
zip_url = f"http://localhost:{port}/{zip_name}"
|
|
viewer_url = f"https://cua.ai/trajectory-viewer?zip={quote(zip_url, safe='')}"
|
|
|
|
machine = session_dir.parent.name
|
|
session_ts = session_dir.name
|
|
print(f"Serving: {machine}/{session_ts}")
|
|
print(f"Viewer: {viewer_url}")
|
|
print("Stop with: cua trajectory stop")
|
|
|
|
try:
|
|
webbrowser.open(viewer_url)
|
|
except Exception:
|
|
pass
|
|
|
|
return 0
|
|
|
|
|
|
# ── stop ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _is_our_server(pid: int) -> bool:
|
|
"""Check if the given PID is a Python process we spawned."""
|
|
try:
|
|
os.kill(pid, 0) # Check if process exists (doesn't actually send a signal)
|
|
except (ProcessLookupError, PermissionError):
|
|
return False
|
|
# On macOS/Linux, verify it's a Python process via /proc or ps
|
|
try:
|
|
import platform
|
|
|
|
if platform.system() != "Windows":
|
|
result = subprocess.run(
|
|
["ps", "-p", str(pid), "-o", "comm="],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=2,
|
|
)
|
|
comm = result.stdout.strip().lower()
|
|
return "python" in comm
|
|
except Exception:
|
|
pass
|
|
return True # On Windows or if ps fails, trust the PID file
|
|
|
|
|
|
def _stop_server(quiet: bool = False) -> bool:
|
|
"""Kill the background file server. Returns True if a server was stopped."""
|
|
if not _PID_FILE.exists():
|
|
if not quiet:
|
|
print("No file server is running.")
|
|
return False
|
|
|
|
try:
|
|
info = json.loads(_PID_FILE.read_text())
|
|
pid = info["pid"]
|
|
except Exception:
|
|
_PID_FILE.unlink(missing_ok=True)
|
|
if not quiet:
|
|
print("Could not read server PID file.", file=sys.stderr)
|
|
return False
|
|
|
|
stopped = False
|
|
try:
|
|
if not _is_our_server(pid):
|
|
if not quiet:
|
|
print("Server was not running (stale PID file removed).")
|
|
_PID_FILE.unlink(missing_ok=True)
|
|
return False
|
|
os.kill(pid, signal.SIGTERM)
|
|
stopped = True
|
|
if not quiet:
|
|
print(f"Stopped file server (pid {pid}).")
|
|
except ProcessLookupError:
|
|
if not quiet:
|
|
print("Server was not running (stale PID file removed).")
|
|
except Exception as e:
|
|
if not quiet:
|
|
print(f"Failed to stop server: {e}", file=sys.stderr)
|
|
finally:
|
|
_PID_FILE.unlink(missing_ok=True)
|
|
|
|
return stopped
|
|
|
|
|
|
def _cmd_stop(_args: argparse.Namespace) -> int:
|
|
_stop_server()
|
|
return 0
|
|
|
|
|
|
# ── clean ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _cmd_clean(args: argparse.Namespace) -> int:
|
|
older_than = getattr(args, "older_than", None)
|
|
machine = getattr(args, "machine", None)
|
|
yes = getattr(args, "yes", False)
|
|
|
|
sessions = list_trajectories(machine=machine)
|
|
if not sessions:
|
|
print("No trajectory sessions to clean.")
|
|
return 0
|
|
|
|
if older_than is not None:
|
|
from datetime import datetime, timedelta
|
|
|
|
cutoff = datetime.now() - timedelta(days=older_than)
|
|
sessions = [s for s in sessions if datetime.fromisoformat(s["created"]) < cutoff]
|
|
|
|
if not sessions:
|
|
print("No sessions match the criteria.")
|
|
return 0
|
|
|
|
if not yes:
|
|
print(f"Will delete {len(sessions)} session(s):")
|
|
for s in sessions:
|
|
print(f" {s['machine']}/{s['session']} ({s['turns']} turns)")
|
|
try:
|
|
answer = input("Continue? [y/N] ").strip().lower()
|
|
except (EOFError, KeyboardInterrupt):
|
|
print("\nCancelled.")
|
|
return 1
|
|
if answer != "y":
|
|
print("Cancelled.")
|
|
return 1
|
|
|
|
deleted = clean_trajectories(older_than_days=older_than, machine=machine)
|
|
print(f"Deleted {len(deleted)} session(s).")
|
|
return 0
|
|
|
|
|
|
# ── dispatch ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def execute(args: argparse.Namespace) -> int:
|
|
dispatch = {
|
|
"ls": _cmd_ls,
|
|
"view": _cmd_view,
|
|
"clean": _cmd_clean,
|
|
"stop": _cmd_stop,
|
|
}
|
|
handler = dispatch.get(args.traj_action)
|
|
if not handler:
|
|
return 1
|
|
return handler(args)
|