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]>
This commit is contained in:
2026-07-14 22:06:36 -05:00
co-authored by Cursor
commit 0d6b0b2f80
76 changed files with 5697 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
from pathlib import Path
from syncgames.config import AgentConfig, save_agent_config, try_load_agent_config
def test_save_and_reload_agent(tmp_path: Path, monkeypatch):
monkeypatch.setenv("SYNCGAMES_CONFIG", str(tmp_path))
cfg = AgentConfig(
device_id="laptop",
endpoint_url="https://syncgames-s3.example.com",
bucket="syncgames",
access_key="ak",
secret_key="sk",
config_root=tmp_path,
)
path = save_agent_config(cfg)
assert path.exists()
loaded = try_load_agent_config(tmp_path)
assert loaded is not None
assert loaded.device_id == "laptop"
assert loaded.endpoint_url.endswith("example.com")
assert loaded.secret_key == "sk"
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
from pathlib import Path
import pytest
from syncgames.config import AgentConfig, GameConfig
from syncgames.errors import StaleWipError
from syncgames.session import end_session, start_session
from syncgames.store import FilesystemStore
@pytest.fixture
def env(tmp_path: Path):
cfg_root = tmp_path / "cfg"
games = cfg_root / "games"
games.mkdir(parents=True)
native = tmp_path / "native" / "saves"
native.mkdir(parents=True)
(native / "slot1.sav").write_bytes(b"v1")
cfg = AgentConfig(
device_id="pc-test",
endpoint_url="",
store="filesystem",
ssot_root=tmp_path / "ssot",
config_root=cfg_root,
state_root=tmp_path / "state",
)
game = GameConfig(
id="demo",
name="Demo",
platform="other",
paths=[str(native)],
versions_to_keep=3,
)
# seed remote empty then first end will push after start with empty live
store = FilesystemStore(cfg.ssot_root)
return cfg, store, game, native
def test_start_end_roundtrip(env):
cfg, store, game, native = env
start_session(cfg, store, game)
(native / "slot1.sav").write_bytes(b"v2-progress")
meta = end_session(cfg, store, game)
assert meta.live_hash
assert store.list_live("demo")
def test_hash_gate_blocks_stale(env):
cfg, store, game, native = env
start_session(cfg, store, game)
(native / "slot1.sav").write_bytes(b"A")
end_session(cfg, store, game)
# Simulate stale session with old parent after remote advanced elsewhere
start_session(cfg, store, game)
# Mutate remote live out from under us
lives = list((Path(cfg.ssot_root) / "games" / "demo" / "live").rglob("slot1.sav"))
assert lives
lives[0].write_bytes(b"REMOTE-NEWER")
# Update meta live_hash to mismatch parent
meta = store.get_meta("demo")
assert meta
meta.raw["live_hash"] = "sha256:deadbeef"
store.put_meta("demo", meta)
(native / "slot1.sav").write_bytes(b"local-stale-wip")
with pytest.raises(StaleWipError):
end_session(cfg, store, game)
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
import json
from pathlib import Path
from syncgames.hashutil import tree_hash
from syncgames.store import FilesystemStore, empty_meta
def test_tree_hash_stable():
a = {"b.bin": "sha256:aa", "a.bin": "sha256:bb"}
b = {"a.bin": "sha256:bb", "b.bin": "sha256:aa"}
assert tree_hash(a) == tree_hash(b)
def test_filesystem_roundtrip(tmp_path: Path):
store = FilesystemStore(tmp_path / "ssot")
game = "test-game"
meta = empty_meta(game, 3)
store.put_meta(game, meta)
assert store.get_meta(game) is not None
local = tmp_path / "wip"
local.mkdir()
(local / "save.dat").write_bytes(b"hello-save")
checksums = store.upload_prefix(game, "live", "", local)
assert "save.dat" in checksums
dest = tmp_path / "down"
got = store.download_live(game, dest)
assert got["save.dat"].startswith("sha256:")
assert (dest / "save.dat").read_bytes() == b"hello-save"
store.upload_prefix(game, "history", "phone/20260101T000000Z", local)
hist = store.list_history(game)
assert any(h.startswith("phone/") for h in hist)
probe = store.probe()
assert probe.startswith("sha256:")
def test_meta_json_roundtrip(tmp_path: Path):
store = FilesystemStore(tmp_path)
m = empty_meta("g1")
m.raw["lease"] = {
"holder": "pc",
"expires_at": "2099-01-01T00:00:00Z",
"session_id": "x",
}
store.put_meta("g1", m)
again = store.get_meta("g1")
assert again is not None
assert again.lease["holder"] == "pc"
# ensure valid json file
text = (tmp_path / "games" / "g1" / "meta.json").read_text()
json.loads(text)