Session-gated MinIO save sync with AppImage GUI, CLI edit/session flow, and Gitea release helper. Co-authored-by: Cursor <[email protected]>
80 lines
2.4 KiB
Python
80 lines
2.4 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import timedelta
|
|
from typing import Any
|
|
|
|
from syncgames.errors import LeaseHeldError
|
|
from syncgames.store import Meta, ObjectStore, parse_expires, utc_now
|
|
|
|
|
|
def lease_active(lease: dict[str, Any] | None) -> bool:
|
|
if not lease:
|
|
return False
|
|
exp = parse_expires(lease.get("expires_at"))
|
|
if exp is None:
|
|
return False
|
|
return exp > utc_now()
|
|
|
|
|
|
def acquire_lease(
|
|
store: ObjectStore,
|
|
game_id: str,
|
|
device_id: str,
|
|
*,
|
|
ttl_hours: int,
|
|
versions_to_keep: int,
|
|
break_other: bool = False,
|
|
) -> Meta:
|
|
meta = store.get_meta(game_id)
|
|
if meta is None:
|
|
from syncgames.store import empty_meta
|
|
|
|
meta = empty_meta(game_id, versions_to_keep)
|
|
|
|
lease = meta.lease
|
|
if lease_active(lease) and lease and lease.get("holder") != device_id:
|
|
if not break_other:
|
|
raise LeaseHeldError(
|
|
f"Lease held by {lease.get('holder')} until {lease.get('expires_at')}"
|
|
)
|
|
|
|
session_id = str(uuid.uuid4())
|
|
expires = utc_now() + timedelta(hours=ttl_hours)
|
|
meta.raw["lease"] = {
|
|
"holder": device_id,
|
|
"expires_at": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
"session_id": session_id,
|
|
}
|
|
meta.raw["versions_to_keep"] = versions_to_keep
|
|
store.put_meta(game_id, meta)
|
|
# Re-read to reduce lost-update window (optimistic)
|
|
again = store.get_meta(game_id)
|
|
if again is None or not again.lease or again.lease.get("holder") != device_id:
|
|
raise LeaseHeldError("Lost lease race — another device acquired the lease")
|
|
return again
|
|
|
|
|
|
def release_lease(store: ObjectStore, game_id: str, device_id: str, meta: Meta | None = None) -> Meta:
|
|
meta = meta or store.get_meta(game_id)
|
|
if meta is None:
|
|
from syncgames.store import empty_meta
|
|
|
|
meta = empty_meta(game_id)
|
|
lease = meta.lease
|
|
if lease and lease.get("holder") not in (None, device_id):
|
|
if lease_active(lease):
|
|
raise LeaseHeldError(f"Cannot release lease owned by {lease.get('holder')}")
|
|
meta.raw["lease"] = None
|
|
store.put_meta(game_id, meta)
|
|
return meta
|
|
|
|
|
|
def break_lease(store: ObjectStore, game_id: str) -> Meta:
|
|
from syncgames.store import empty_meta
|
|
|
|
meta = store.get_meta(game_id) or empty_meta(game_id)
|
|
meta.raw["lease"] = None
|
|
store.put_meta(game_id, meta)
|
|
return meta
|