Session-gated MinIO save sync with AppImage GUI, CLI edit/session flow, and Gitea release helper. Co-authored-by: Cursor <[email protected]>
261 lines
9.0 KiB
Python
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"
|