from __future__ import annotations import json import shutil import time from pathlib import Path from syncgames.config import AgentConfig, GameConfig from syncgames.errors import GameRunningError, StaleWipError, SyncGamesError from syncgames.hashutil import checksum_dir, tree_hash from syncgames.lease import acquire_lease, release_lease from syncgames.store import Meta, ObjectStore, empty_meta, iso_ts, utc_now from syncgames.watchers import is_game_running def session_path(cfg: AgentConfig, game_id: str) -> Path: return cfg.state_root / "sessions" / f"{game_id}.json" def wip_dir(cfg: AgentConfig, game_id: str) -> Path: return cfg.state_root / "wip" / game_id def load_session(cfg: AgentConfig, game_id: str) -> dict | None: p = session_path(cfg, game_id) if not p.exists(): return None return json.loads(p.read_text(encoding="utf-8")) def save_session(cfg: AgentConfig, game_id: str, data: dict) -> None: p = session_path(cfg, game_id) p.parent.mkdir(parents=True, exist_ok=True) p.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") def clear_session(cfg: AgentConfig, game_id: str) -> None: p = session_path(cfg, game_id) if p.exists(): p.unlink() w = wip_dir(cfg, game_id) if w.exists(): shutil.rmtree(w) def collect_native_files(game: GameConfig) -> list[tuple[Path, str]]: """Return (absolute_path, archive_relpath) for all save files.""" out: list[tuple[Path, str]] = [] for base in game.resolved_paths(): if not base.exists(): continue if base.is_file(): out.append((base, base.name)) continue for path in sorted(p for p in base.rglob("*") if p.is_file()): # Skip obvious temp / lock clutter if path.suffix.lower() in {".tmp", ".lock", ".part"}: continue if path.name.startswith("."): continue rel = f"{base.name}/{path.relative_to(base).as_posix()}" out.append((path, rel)) return out def stage_native_to_wip(cfg: AgentConfig, game: GameConfig) -> Path: dest = wip_dir(cfg, game.id) if dest.exists(): shutil.rmtree(dest) dest.mkdir(parents=True, exist_ok=True) for src, rel in collect_native_files(game): target = dest / rel target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, target) return dest def install_staging_to_native(staging: Path, game: GameConfig) -> None: """Map staging layout back onto configured native paths.""" # Staging uses / for directories and for single files for base in game.resolved_paths(): if base.suffix and not base.exists() and not any(base.parent.glob(base.name)): # treat as file target candidate = staging / base.name if candidate.is_file(): base.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(candidate, base) continue # Directory target: look for staging// staged = staging / base.name if staged.is_dir(): base.mkdir(parents=True, exist_ok=True) for path in staged.rglob("*"): if path.is_file(): rel = path.relative_to(staged) target = base / rel target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(path, target) elif (staging / base.name).is_file() and base.parent.exists(): # file named same as last component under parent shutil.copy2(staging / base.name, base) def wait_until_idle(game: GameConfig, *, timeout_s: float = 30.0) -> None: """Poll until the game process exits or timeout.""" deadline = time.time() + timeout_s while is_game_running(game): if time.time() > deadline: raise GameRunningError( f"Game still running for {game.id} after {timeout_s:.0f}s — quit the game or use --force" ) time.sleep(1.0) def start_session(cfg: AgentConfig, store: ObjectStore, game: GameConfig) -> Meta: meta = acquire_lease( store, game.id, cfg.device_id, ttl_hours=cfg.lease_ttl_hours, versions_to_keep=game.versions_to_keep, ) staging = wip_dir(cfg, game.id) if staging.exists(): shutil.rmtree(staging) staging.mkdir(parents=True, exist_ok=True) checksums = store.download_live(game.id, staging) install_staging_to_native(staging, game) parent = meta.live_hash or tree_hash(checksums) if checksums else None if checksums and not meta.live_hash: parent = tree_hash(checksums) save_session( cfg, game.id, { "game_id": game.id, "parent_live_hash": parent, "session_id": (meta.lease or {}).get("session_id"), "started_at": utc_now().strftime("%Y-%m-%dT%H:%M:%SZ"), "device_id": cfg.device_id, }, ) return meta def end_session( cfg: AgentConfig, store: ObjectStore, game: GameConfig, *, force: bool = False, force_hash: bool = False, ) -> Meta: sess = load_session(cfg, game.id) if not sess: raise SyncGamesError( f"No local session for {game.id}; run start first", key="config_error" ) if not force: try: wait_until_idle(game) except GameRunningError: raise if is_game_running(game) and not force: raise GameRunningError(f"Refuse end while {game.id} process is running") staging = stage_native_to_wip(cfg, game) checksums = checksum_dir(staging) wip_hash = tree_hash(checksums) if checksums else None meta = store.get_meta(game.id) or empty_meta(game.id, game.versions_to_keep) current_live = meta.live_hash parent = sess.get("parent_live_hash") if current_live and parent and current_live != parent and not force_hash: raise StaleWipError( f"Stale WIP: parent {parent} != live {current_live}. " "Refuse push to avoid LOPE. Use --force-restore only if intentional." ) ts = iso_ts() hist_prefix = f"{cfg.device_id}/{ts}" store.upload_prefix(game.id, "history", hist_prefix, staging) store.upload_prefix(game.id, "live", "", staging) store.delete_live_not_in(game.id, set(checksums.keys())) meta.raw["live_hash"] = wip_hash meta.raw["file_checksums"] = checksums meta.raw["updated_at"] = utc_now().strftime("%Y-%m-%dT%H:%M:%SZ") meta.raw["updated_by"] = cfg.device_id meta.raw["versions_to_keep"] = game.versions_to_keep meta.raw["lease"] = None store.put_meta(game.id, meta) store.prune_device_history(game.id, cfg.device_id, game.versions_to_keep) clear_session(cfg, game.id) return meta def restore_session( cfg: AgentConfig, store: ObjectStore, game: GameConfig, device_ts: str, ) -> Meta: acquire_lease( store, game.id, cfg.device_id, ttl_hours=cfg.lease_ttl_hours, versions_to_keep=game.versions_to_keep, ) store.copy_history_to_live(game.id, device_ts) staging = wip_dir(cfg, game.id) if staging.exists(): shutil.rmtree(staging) checksums = store.download_live(game.id, staging) live_hash = tree_hash(checksums) if checksums else None meta = store.get_meta(game.id) or empty_meta(game.id, game.versions_to_keep) meta.raw["live_hash"] = live_hash meta.raw["file_checksums"] = checksums meta.raw["updated_at"] = utc_now().strftime("%Y-%m-%dT%H:%M:%SZ") meta.raw["updated_by"] = cfg.device_id store.put_meta(game.id, meta) # Leave lease held; user should start-equivalent install install_staging_to_native(staging, game) save_session( cfg, game.id, { "game_id": game.id, "parent_live_hash": live_hash, "session_id": (meta.lease or {}).get("session_id"), "started_at": utc_now().strftime("%Y-%m-%dT%H:%M:%SZ"), "device_id": cfg.device_id, "restored_from": device_ts, }, ) return meta def ensure_game_remote(store: ObjectStore, game: GameConfig) -> None: meta = store.get_meta(game.id) if meta is None: m = empty_meta(game.id, game.versions_to_keep) store.put_meta(game.id, m) def status_text(cfg: AgentConfig, store: ObjectStore, game: GameConfig | None) -> str: lines: list[str] = [f"device: {cfg.device_id}", f"store: {cfg.store}"] games = [game] if game else __import__("syncgames.config", fromlist=["list_games"]).list_games(cfg) for g in games: meta = store.get_meta(g.id) sess = load_session(cfg, g.id) lease = meta.lease if meta else None lines.append(f"\n[{g.id}] {g.name}") lines.append(f" live_hash: {(meta.live_hash if meta else None)}") lines.append(f" lease: {lease}") lines.append(f" local_session: {sess}") lines.append(f" running: {is_game_running(g)}") return "\n".join(lines) + "\n"