Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:03:19 +08:00

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)