feat: gate a phone deploy on a peer already holding the device's state

`adb install -r` preserves app data and phone_deploy already refuses every
path that would not, but "preserved" is a property of the happy path -- a
failed install or a later wipe leaves unpublished entries existing nowhere
else. The log is CRDT-synced precisely so a recoverable copy exists, so the
deploy now checks that one does before touching the device.

The obvious device-side signal does not work and would be a gate that can
never pass: 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*. So the check is remote-side
-- pull the shared log and assert it holds recent entries, which answers "is
there a copy off the device" directly.

Advisory by default; SYNC_GATE_STRICT=1 makes it block (exit 38). A network
outage must not stop a fix reaching the phone.

The shell half lives in ~/.claude/scripts/lib/phone_deploy_sync.sh. Verified
against the real phone: passes when a peer is current, skips cleanly for an
unsynced or not-yet-installed app, and blocks with exit 38 in strict mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
This commit is contained in:
Krzysztof Rudnicki 2026-08-23 07:41:26 +02:00
parent 0ca95eda5b
commit 93669776b6
2 changed files with 79 additions and 1 deletions

View File

@ -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

78
scripts/sync_freshness.py Executable file
View File

@ -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())