diet-guard/diet_guard/_sync.py
Krzysztof kuhy Rudnicki f7f06aee53
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.12) (push) Has been cancelled
Remove unused features; fix reminder false alarms; share all state
Four asks, plus the fixes two review passes turned up.

Removals (all unused):
- "Repeat last meal" button, and the reward/temptation-bundling prompt it
  was the sole trigger for. Replaced with a post-log progress card showing
  kcal against budget, macros, and the adherence streak.
- The multi-item meal builder, on both the gate and the phone. The stored
  `components` field stays: nothing writes it now, but historical composite
  entries and the food bank still read it and it is part of the wire format.
- Meal photos entirely: 13 files, `imagePath`, the photo filter, the
  wrapper's /blobs/ route, image_picker, and the CAMERA permission.

Reminder false alarms had three real causes, all fixed:
- Logging never reconciled notifications, so a reminder sat on screen up to
  15 minutes after the meal was logged -- on the phone itself.
- The background check never pulled, so a PC-logged meal nagged forever
  until the app was opened. Now mirrors _cli_gate._should_lock: cheap local
  check, network only when a slot looks due, then re-check.
- Meals before 08:00 / after 22:00 were attributed to no slot at all. New
  slot_for_log/slotForLog clamps them to the first/last slot, byte-identical
  across Python and Dart. Deliberately separate from elapsed_slots, which
  would otherwise make every slot fall due at 23:00.

Budget history, so lowering the budget stops rewriting the past:
- Forward-only (effective_from, kcal) entries; each day is judged against
  the budget in force then. Verified live: setting 2000 left a 36-day
  adherence streak and the whole calendar pixel-identical.
- Rides as hist:<date> fields on the existing budget record. crdt_sync
  merges per-field over the union of field names and both devices push the
  merged log, so a device that predates the feature relays them untouched --
  no coordinated rollout, no second document.
- The seed guard tests emptiness, not file presence: _sync can write an
  empty history after merging with a pre-feature peer, and a presence check
  would then never seed again, silently reclassifying every past day.

One shared source of truth for every piece of data:
- Body weight now travels as its own `weight` CRDT field. Inside `value` it
  was collateral damage of whole-map LWW -- any phone budget edit deleted it,
  and the protein target with it.
- Both halves of the food bank sync. The curated half (foods added without
  eating them) was the last device-local state. The derived half merges with
  count as the clock, so LWW means max-count-wins; the log stays
  authoritative for membership, because a CRDT union never shrinks and a
  peer's stale copy would otherwise resurrect a deleted food permanently.
- Bank adapters honour tombstones, and Dart parses bank records defensively:
  a cast failure there is an Error, not an Exception, so one malformed
  remote record would have broken phone sync permanently.

_test_guard.py blocks any pathlib write to real user state while pytest is
in the process, whatever conftest is loaded. A throwaway test run outside
the package conftest destroyed a real GitHub token during this work; this
makes that class of accident impossible rather than merely discouraged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 16:39:52 +02:00

345 lines
13 KiB
Python

"""Cross-device log sync orchestration for diet_guard.
Pulls every other device's pushed log from GitHub-backed dumb storage
(``crdt_sync.GitHubSyncClient``), merges with the local log via
``crdt_sync``'s shared CRDT scheme (:mod:`diet_guard._sync_merge` adapts
diet_guard's entries to/from ``crdt_sync.Record``), re-signs every persisted
entry, rebuilds the food bank, and pushes this device's own merged log back
up in the new Record-based wire format.
The daily budget syncs alongside the food log in the same tick (see
:func:`_sync_budget`, called from :func:`run_sync`): a sibling
``budget.json`` per device, merged the same way but last-writer-wins per
edit rather than union-of-immutable-entries, since a budget (unlike a
food-log entry) can be edited repeatedly.
"""
from __future__ import annotations
import json
import logging
from crdt_sync import GitHubSyncClient, GitHubSyncError, Log, merge_logs
from diet_guard._budget import read_raw_record, write_raw_record
from diet_guard._budget_history import (
history_to_json,
load_entries,
write_raw_history,
)
from diet_guard._constants import (
SYNC_DEVICE_ID,
SYNC_REPO_NAME,
SYNC_REPO_OWNER,
SYNC_TIMEOUT_SECONDS,
SYNC_TOKEN_FILE,
)
from diet_guard._foodbank import read_food_bank, rebuild_food_bank, write_food_bank
from diet_guard._foodbank_manual import read_manual_bank, write_manual_bank
from diet_guard._state import DayLog, read_raw_log, resign_entry, write_raw_log
from diet_guard._sync_merge import (
budget_to_log,
daylog_to_log,
food_bank_to_log,
log_to_budget,
log_to_daylog,
log_to_food_bank,
log_to_history,
log_to_manual_bank,
manual_bank_to_log,
parse_remote_budget,
parse_remote_food_bank,
parse_remote_log,
parse_remote_manual_bank,
)
_logger = logging.getLogger(__name__)
_DEVICES_DIR = "diet-guard-sync/devices"
class SyncError(Exception):
"""Raised when a sync run cannot even start (no usable PAT)."""
def _device_log_path(device_id: str) -> str:
"""Return the repo-relative path a device's full log is pushed to."""
return f"{_DEVICES_DIR}/{device_id}/food_log.json"
def _device_food_bank_path(device_id: str) -> str:
"""Return one device's pushed derived-food-bank path."""
return f"{_DEVICES_DIR}/{device_id}/food_bank.json"
def _device_manual_bank_path(device_id: str) -> str:
"""Return one device's pushed curated-food-bank path."""
return f"{_DEVICES_DIR}/{device_id}/food_bank_manual.json"
def _device_budget_path(device_id: str) -> str:
"""Return the repo-relative path a device's budget is pushed to."""
return f"{_DEVICES_DIR}/{device_id}/budget.json"
def _read_token() -> str:
"""Return the saved sync PAT, stripped of trailing whitespace.
Raises:
SyncError: If the token file is missing or empty -- the user has not
completed the one-time github.com setup step yet.
"""
if not SYNC_TOKEN_FILE.exists():
message = (
f"no sync token at {SYNC_TOKEN_FILE} -- create a fine-grained "
"GitHub PAT scoped to the syncs repo's contents and "
f"save it there (mode 600), then re-run sync"
)
raise SyncError(message)
token = SYNC_TOKEN_FILE.read_text().strip()
if not token:
msg = f"{SYNC_TOKEN_FILE} is empty"
raise SyncError(msg)
return token
def _pull_remote_logs(client: GitHubSyncClient) -> list[Log]:
"""Return every other device's last-pushed log, skipping this one.
A device whose pushed file is corrupt, truncated, or otherwise
unparsable (new or old wire format) is logged and skipped, same as one
that has never pushed at all -- GitHub is an external system boundary,
and one bad device's file must not stall merging in every other
device's.
"""
remote_logs: list[Log] = []
for device_id in client.list_directory(_DEVICES_DIR):
if device_id == SYNC_DEVICE_ID:
continue
text = client.get_file_text(_device_log_path(device_id))
if text is None:
continue
try:
remote_logs.append(parse_remote_log(text))
except (TypeError, KeyError, ValueError, json.JSONDecodeError):
_logger.warning("Unparsable log pushed by device %r, skipping", device_id)
return remote_logs
def _sync_food_bank(client: GitHubSyncClient) -> None:
"""Pull, merge, persist and push the log-derived food bank.
Runs *after* the local rebuild in :func:`run_sync`, so this device's own
records already reflect the merged log; the merge then unions in whatever
another device knows and max-count wins per food (see
:func:`diet_guard._sync_merge.food_bank_to_log`).
Strictly speaking the bank is derivable from the already-synced log, so
both devices converge on their own eventually. Syncing it makes them
agree *now*, and publishes the bank so a fresh device has autocomplete
before it has replayed anything.
**The log stays authoritative for which foods exist.** A CRDT union
never shrinks, so without this the max-count merge would resurrect a food
whose entries were all undone -- a peer's stale copy would out-clock the
local absence and be written back and re-pushed forever, un-deletable.
Restricting the result to the foods the freshly-rebuilt local bank still
contains fixes that, and stays identical across devices because that
rebuild comes from the *merged* log both devices share.
"""
local = read_food_bank()
merged = food_bank_to_log(local)
for device_id in client.list_directory(_DEVICES_DIR):
if device_id == SYNC_DEVICE_ID:
continue
text = client.get_file_text(_device_food_bank_path(device_id))
if text is None:
continue
try:
merged = merge_logs(merged, parse_remote_food_bank(text))
except (TypeError, KeyError, ValueError, json.JSONDecodeError):
_logger.warning(
"Unparsable food bank pushed by device %r, skipping",
device_id,
)
if not merged:
return
resolved = {
name: record
for name, record in log_to_food_bank(merged).items()
if name in local
}
write_food_bank(resolved)
merged = food_bank_to_log(resolved)
client.put_file_text(
_device_food_bank_path(SYNC_DEVICE_ID),
json.dumps(
{record_id: record.to_dict() for record_id, record in merged.items()},
indent=2,
),
message="diet_guard sync",
)
def _sync_manual_bank(client: GitHubSyncClient) -> None:
"""Pull, merge, persist and push the hand-curated food bank.
Curated entries are the one part of the bank that is not derivable from
the food log (see :mod:`diet_guard._foodbank_manual`), so unlike
``food_bank.json`` they need a real merge: last-writer-wins per food name
by edit time, union across devices.
"""
merged = manual_bank_to_log(read_manual_bank())
for device_id in client.list_directory(_DEVICES_DIR):
if device_id == SYNC_DEVICE_ID:
continue
text = client.get_file_text(_device_manual_bank_path(device_id))
if text is None:
continue
try:
merged = merge_logs(merged, parse_remote_manual_bank(text))
except (TypeError, KeyError, ValueError, json.JSONDecodeError):
_logger.warning(
"Unparsable curated food bank pushed by device %r, skipping",
device_id,
)
if not merged:
# No device has curated anything: nothing to persist, and pushing an
# empty object every tick would be pure churn.
return
write_manual_bank(log_to_manual_bank(merged))
client.put_file_text(
_device_manual_bank_path(SYNC_DEVICE_ID),
json.dumps(
{record_id: record.to_dict() for record_id, record in merged.items()},
indent=2,
),
message="diet_guard sync",
)
def _sync_budget(client: GitHubSyncClient) -> None:
"""Pull other devices' budgets, merge, write locally, push this device's.
Runs in the same tick as the food-log sync, reusing the already
authenticated ``client``. Merging is last-writer-wins by edit time (see
:mod:`diet_guard._sync_merge`'s budget adapters), not the food log's
union-of-immutable-entries -- a budget can be edited repeatedly. A
device that has never run ``init`` neither contributes a local record
to the merge nor overwrites a real budget pulled from elsewhere, and if
*no* device has ever set one, nothing is written or pushed.
"""
merged = budget_to_log(read_raw_record(), load_entries())
for device_id in client.list_directory(_DEVICES_DIR):
if device_id == SYNC_DEVICE_ID:
continue
text = client.get_file_text(_device_budget_path(device_id))
if text is None:
continue
try:
merged = merge_logs(merged, parse_remote_budget(text))
except (TypeError, KeyError, ValueError, json.JSONDecodeError):
_logger.warning(
"Unparsable budget pushed by device %r, skipping",
device_id,
)
merged_record = log_to_budget(merged)
if merged_record is None:
return
write_raw_record(merged_record)
merged_history = log_to_history(merged)
# Only write back when the merge actually carried history. A pre-feature
# peer contributes none, and persisting an empty document would look like
# "history already exists" to any presence-based check and stop the local
# seed from ever running.
if merged_history:
write_raw_history(history_to_json(merged_history))
push_json = json.dumps(
{record_id: record.to_dict() for record_id, record in merged.items()},
indent=2,
)
client.put_file_text(
_device_budget_path(SYNC_DEVICE_ID),
push_json,
message="diet_guard sync",
)
def run_sync() -> DayLog:
"""Run one full sync tick: pull, merge, re-sign, persist, push.
Every persisted entry is re-signed regardless of origin (not just
phone-origin ones): a signature computed on another device cannot be
trusted as this device's shared key sees it, and an inbound entry with no
signature at all would otherwise be silently dropped on the very next
read by :func:`diet_guard._state.load_log`. The daily budget syncs in
the same tick (see :func:`_sync_budget`), reusing this same client.
Returns:
The merged log as it now sits on disk locally, post re-sign.
Raises:
SyncError: If the local PAT is missing or empty.
crdt_sync.GitHubSyncError: Propagated from the GitHub client for any
transport failure -- the caller (CLI/timer) decides how to
report it.
"""
token = _read_token()
client = GitHubSyncClient(
SYNC_REPO_OWNER, SYNC_REPO_NAME, token, timeout_seconds=SYNC_TIMEOUT_SECONDS
)
merged = daylog_to_log(read_raw_log())
for remote_log in _pull_remote_logs(client):
merged = merge_logs(merged, remote_log)
merged_daylog = log_to_daylog(merged)
resigned: DayLog = {
day: [resign_entry(entry) for entry in entries]
for day, entries in merged_daylog.items()
}
write_raw_log(resigned)
rebuild_food_bank(resigned)
_sync_budget(client)
_sync_food_bank(client)
_sync_manual_bank(client)
push_log = daylog_to_log(resigned)
push_json = json.dumps(
{record_id: record.to_dict() for record_id, record in push_log.items()},
indent=2,
)
client.put_file_text(
_device_log_path(SYNC_DEVICE_ID),
push_json,
message="diet_guard sync",
)
return resigned
def pull_shared_log() -> str | None:
"""Run a sync tick, failing closed instead of raising.
A thin wrapper over :func:`run_sync` for callers that must never crash on a
sync error: the gate's automatic pre-lock refresh and the lock screen's
manual "Fetch from sync" button. Returns ``None`` on success, or a short
human-readable reason when the pull could not complete, so the caller keeps
its own lock decision rather than failing open.
The three caught types are the whole realistic failure surface of a run:
:class:`SyncError` (no/empty token), :class:`~crdt_sync.GitHubSyncError`
(the client wraps every ``requests`` transport error in this), and
:class:`OSError` (reading the token or writing the merged log back). A bug
outside these is deliberately *not* swallowed -- it should surface, not be
silently reported as a sync outage.
"""
try:
run_sync()
except (SyncError, GitHubSyncError, OSError) as exc:
return f"sync unavailable ({exc})"
return None