#!/usr/bin/env python3 """Pull completed TG3 data-collection episodes from the robot atomically.""" from __future__ import annotations import argparse import hashlib import json import os import re import shlex import shutil import signal import subprocess import time from pathlib import Path from typing import Any, Sequence SAFE_EPISODE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$") def atomic_write_json(path: Path, payload: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_suffix(path.suffix + ".tmp") temporary.write_text( json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) os.replace(temporary, path) def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: for block in iter(lambda: stream.read(1024 * 1024), b""): digest.update(block) return digest.hexdigest() def safe_episode_name(value: str) -> bool: return bool(SAFE_EPISODE_NAME.fullmatch(value)) and value not in (".", "..") def safe_relative_path(value: object) -> Path: if not isinstance(value, str) or not value: raise ValueError("manifest file path must be a non-empty string") path = Path(value) if path.is_absolute() or ".." in path.parts: raise ValueError(f"unsafe manifest file path: {value!r}") return path def parse_manifest(raw: str, expected_episode: str) -> dict[str, Any]: payload = json.loads(raw) if not isinstance(payload, dict): raise ValueError("manifest root must be an object") if payload.get("state") != "complete": raise ValueError("remote episode is not complete") if payload.get("episode_id") != expected_episode: raise ValueError("manifest episode_id does not match directory name") files = payload.get("files") if not isinstance(files, list) or not files: raise ValueError("manifest files must be a non-empty list") saw_mcap = False for entry in files: if not isinstance(entry, dict): raise ValueError("manifest file entry must be an object") relative = safe_relative_path(entry.get("path")) saw_mcap = saw_mcap or relative.suffix == ".mcap" size = entry.get("size") digest = entry.get("sha256") if isinstance(size, bool) or not isinstance(size, int) or size < 0: raise ValueError(f"invalid size for {relative}") if not ( isinstance(digest, str) and len(digest) == 64 and all(character in "0123456789abcdef" for character in digest) ): raise ValueError(f"invalid sha256 for {relative}") if not saw_mcap: raise ValueError("manifest contains no MCAP file") return payload def validate_episode_dir(directory: Path, manifest: dict[str, Any]) -> None: ready = directory / "READY" if not ready.is_file() or ready.is_symlink(): raise ValueError("episode has no regular READY marker") for entry in manifest["files"]: relative = safe_relative_path(entry["path"]) path = directory / relative if not path.is_file() or path.is_symlink(): raise ValueError(f"missing copied file: {relative}") if path.stat().st_size != entry["size"]: raise ValueError(f"size mismatch: {relative}") if sha256_file(path) != entry["sha256"]: raise ValueError(f"sha256 mismatch: {relative}") class CommandRunner: def run( self, command: Sequence[str], *, timeout: float, ) -> subprocess.CompletedProcess[str]: return subprocess.run( list(command), check=False, capture_output=True, text=True, timeout=timeout, ) class DataGetSync: def __init__( self, *, remote: str, remote_ready: str, destination: Path, status_file: Path, runner: CommandRunner | None = None, ssh_timeout_s: float = 8.0, reserve_bytes: int = 1_073_741_824, verify_existing: bool = False, ) -> None: self.remote = remote self.remote_ready = remote_ready.rstrip("/") self.destination = destination self.status_file = status_file self.runner = runner or CommandRunner() self.ssh_timeout_s = ssh_timeout_s self.reserve_bytes = reserve_bytes self.verify_existing = verify_existing self.stop_requested = False self.started_at = time.time() self.sync_count = 0 self.last_episode: str | None = None self.last_error = "" @property def ssh_base(self) -> list[str]: return [ "ssh", "-o", "BatchMode=yes", "-o", f"ConnectTimeout={max(1, int(self.ssh_timeout_s))}", self.remote, ] def request_stop(self, _signum: int, _frame: object) -> None: self.stop_requested = True def _write_status(self, state: str) -> None: atomic_write_json( self.status_file, { "state": state, "remote": self.remote, "remote_ready": self.remote_ready, "destination": str(self.destination), "sync_count": self.sync_count, "last_episode": self.last_episode, "last_error": self.last_error, "uptime_s": round(time.time() - self.started_at, 1), "updated_unix_s": time.time(), }, ) def _remote_command(self, command: str) -> str: result = self.runner.run( [*self.ssh_base, command], timeout=self.ssh_timeout_s + 2.0 ) if result.returncode != 0: detail = result.stderr.strip() or result.stdout.strip() raise RuntimeError(f"remote command failed: {detail}") return result.stdout def list_remote_episodes(self) -> list[str]: root = shlex.quote(self.remote_ready) output = self._remote_command( f"find {root} -mindepth 1 -maxdepth 1 -type d -printf '%f\\n'" ) names = sorted({line.strip() for line in output.splitlines() if line.strip()}) unsafe = [name for name in names if not safe_episode_name(name)] if unsafe: raise RuntimeError(f"remote returned unsafe episode names: {unsafe!r}") return names def get_remote_manifest(self, episode: str) -> dict[str, Any]: if not safe_episode_name(episode): raise ValueError(f"unsafe episode name: {episode!r}") episode_path = f"{self.remote_ready}/{episode}" manifest_path = f"{episode_path}/manifest.json" ready_path = f"{episode_path}/READY" raw = self._remote_command( f"test -f {shlex.quote(ready_path)} && cat {shlex.quote(manifest_path)}" ) return parse_manifest(raw, episode) def _enough_local_space(self, manifest: dict[str, Any]) -> bool: required = sum(int(entry["size"]) for entry in manifest["files"]) free = shutil.disk_usage(self.destination).free return free >= required + self.reserve_bytes def sync_episode(self, episode: str) -> bool: if not safe_episode_name(episode): raise ValueError(f"unsafe episode name: {episode!r}") final = self.destination / episode if final.exists(): manifest_path = final / "manifest.json" ready_path = final / "READY" if ( not manifest_path.is_file() or manifest_path.is_symlink() or not ready_path.is_file() or ready_path.is_symlink() ): raise RuntimeError(f"existing destination is incomplete: {final}") manifest = parse_manifest(manifest_path.read_text(encoding="utf-8"), episode) # The first transfer already hashed every listed artifact before # the atomic rename. Re-reading all historical MCAP files every # two seconds would eventually saturate the workstation disk. if self.verify_existing: validate_episode_dir(final, manifest) return False manifest = self.get_remote_manifest(episode) if not self._enough_local_space(manifest): raise RuntimeError("not enough local disk space for episode") incoming = self.destination / ".incoming" / episode incoming.mkdir(parents=True, exist_ok=True) remote_source = f"{self.remote}:{self.remote_ready}/{episode}/" ssh_transport = ( "ssh -o BatchMode=yes " f"-o ConnectTimeout={max(1, int(self.ssh_timeout_s))}" ) result = self.runner.run( [ "rsync", "-a", "--partial", "--protect-args", "-e", ssh_transport, remote_source, str(incoming) + "/", ], timeout=24 * 60 * 60, ) if result.returncode != 0: detail = result.stderr.strip() or result.stdout.strip() raise RuntimeError(f"rsync failed: {detail}") copied_manifest_path = incoming / "manifest.json" if not copied_manifest_path.is_file(): raise RuntimeError("copied episode has no manifest.json") copied_manifest = parse_manifest( copied_manifest_path.read_text(encoding="utf-8"), episode ) if copied_manifest != manifest: raise RuntimeError("remote manifest changed during transfer") validate_episode_dir(incoming, copied_manifest) os.replace(incoming, final) self.sync_count += 1 self.last_episode = episode return True def run_once(self) -> int: self.destination.mkdir(parents=True, exist_ok=True) (self.destination / ".incoming").mkdir(parents=True, exist_ok=True) copied = 0 errors: list[str] = [] try: episodes = self.list_remote_episodes() for episode in episodes: try: if self.sync_episode(episode): copied += 1 except Exception as exc: # One damaged historical episode must not starve newer # ready data. Keep its error visible and continue. errors.append(f"{episode}: {exc}") if errors: raise RuntimeError("; ".join(errors)) self.last_error = "" self._write_status("idle") except Exception as exc: self.last_error = str(exc) self._write_status("error") raise return copied def run_forever(self, poll_seconds: float) -> None: self.destination.mkdir(parents=True, exist_ok=True) while not self.stop_requested: try: self.run_once() except Exception: pass deadline = time.monotonic() + poll_seconds while not self.stop_requested and time.monotonic() < deadline: time.sleep(min(0.2, max(0.0, deadline - time.monotonic()))) self._write_status("stopped") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--remote", default="nvidia@192.168.41.2") parser.add_argument( "--remote-ready", default="/home/nvidia/tg3_data_collection/ready" ) parser.add_argument( "--destination", type=Path, default=Path( "/home/ps/Desktop/TG3_TS1P_OmniSocket_Teleop/Data_Get" ), ) parser.add_argument("--status-file", type=Path) parser.add_argument("--poll-seconds", type=float, default=2.0) parser.add_argument( "--verify-existing", action="store_true", help="rehash already published local episodes (slow; intended for audits)", ) parser.add_argument("--once", action="store_true") return parser.parse_args() def main() -> int: args = parse_args() if args.poll_seconds <= 0.0: raise SystemExit("--poll-seconds must be positive") destination = args.destination.resolve() status_file = args.status_file or destination / "sync_status.json" syncer = DataGetSync( remote=args.remote, remote_ready=args.remote_ready, destination=destination, status_file=status_file, verify_existing=args.verify_existing, ) signal.signal(signal.SIGINT, syncer.request_stop) signal.signal(signal.SIGTERM, syncer.request_stop) if args.once: try: syncer.run_once() except Exception as exc: print(f"data sync failed: {exc}") return 1 return 0 syncer.run_forever(args.poll_seconds) return 0 if __name__ == "__main__": raise SystemExit(main())