diff --git a/app/pubspec.yaml b/app/pubspec.yaml index d38d044..1ccc0a7 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.55+1 +version: 1.0.56+1 environment: sdk: ^3.12.2 diff --git a/scripts/sync_freshness.py b/scripts/sync_freshness.py new file mode 100755 index 0000000..4632c24 --- /dev/null +++ b/scripts/sync_freshness.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Assert that a peer already holds this device's recent food-log entries. + +Used by ``phone_deploy.sh``'s pre-install gate. ``adb install -r`` preserves +app data, and the deploy script refuses every path that would not -- but +"preserved" is a property of the happy path. Only a copy on a peer survives a +failed install or a later wipe, and the log is CRDT-synced precisely so such a +copy exists. + +The check is deliberately **remote-side**. The obvious device-side signal does +not work: the app records a successful tick through ``SyncHealth``, which +writes to SharedPreferences (app-private, unreadable over ADB on a release +build), and it only writes to logcat when a sync *fails*. Watching logcat for +a success marker would be a gate that can never pass, which is worse than no +gate -- it trains you to ignore it. + +So this pulls the shared log and asserts the merged result actually contains +recent entries. It answers "is there a recoverable copy off the device", which +is the question the gate exists to ask. + +Exit codes: 0 a peer holds recent state | 1 it does not, or the remote could +not be reached (the caller decides whether that blocks the deploy). +""" + +from __future__ import annotations + +from datetime import date +import importlib +import logging +import pathlib +import sys + +# Running this as `python3 scripts/sync_freshness.py` puts *scripts/* on +# sys.path, not the repo root, so `import diet_guard` fails unless the package +# happens to be installed -- which it is not on a CI runner that only installs +# requirements.txt. Same reason and same fix as `check_patch_targets.py`. +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +#: How stale the newest synced entry may be before the gate calls it unproven. +#: Generous: a day with no meals logged yet is normal first thing in the +#: morning, and this must not fail a deploy for an ordinary quiet morning. +_MAX_AGE_DAYS = 3 + +_logger = logging.getLogger("sync_freshness") + + +def main() -> int: + """Pull the shared log and report whether it holds recent entries.""" + # Imported here, after the sys.path fix above and behind the entry point, + # so the module's own imports stay stdlib-only and lint-clean. + load_log = importlib.import_module("diet_guard._state").load_log + now_local = importlib.import_module("diet_guard._state").now_local + pull_peer_logs = importlib.import_module("diet_guard._sync_refresh").pull_peer_logs + + reason = pull_peer_logs() + if reason is not None: + _logger.error("could not pull the shared log: %s", reason) + return 1 + + log = load_log() + if not log: + _logger.error("the merged log is empty; no peer holds this device's state") + return 1 + + newest = max(log) + age = (now_local().date() - date.fromisoformat(newest)).days + if age > _MAX_AGE_DAYS: + _logger.error("newest synced day is %s (%dd old)", newest, age) + return 1 + + _logger.info("a peer holds entries through %s (%dd old)", newest, age) + return 0 + + +if __name__ == "__main__": + # The caller is a shell gate, so the reason must reach stderr, not vanish. + logging.basicConfig(level=logging.INFO, format="sync freshness: %(message)s") + sys.exit(main())