Files
DawnsorrowandCursor 0d6b0b2f80 Initial SyncGames tree: agent, Android, deploy, docs.
Session-gated MinIO save sync with AppImage GUI, CLI edit/session flow, and Gitea release helper.

Co-authored-by: Cursor <[email protected]>
2026-07-14 22:06:36 -05:00

261 lines
9.0 KiB
Python

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 <basename>/<rel> for directories and <name> 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/<basename>/
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"