feat: sync meal logs to Google Health - #176
Conversation
- Add durable local-first export queue and revision ledger - Add background worker with retry, backoff, and ordering - Cache OAuth access tokens per account to cut token calls - Add wake-early dispatch so sync starts right after a save - Fix meal edit crash from a missing fat_g bind parameter - Fix worker dropping a queued delete during point recreation - Wire export worker lifecycle and wake signal into container - Update docs and prompts for the new nutrition scopes
| ) | ||
| return | ||
|
|
||
| connection = await self.storage.get_connection(telegram_user_id) |
There was a problem hiding this comment.
[P1] Preserve read-only Health connections
This guard never checks connection.scopes before dispatching an optional nutrition write. If a user reconnects with only read scopes while an older revision is due, Google returns 403 from the nutrition create or batchDelete endpoint. The client classifies that as GoogleHealthAuthError, and the catch below calls mark_reauthorization_required, clearing the refresh token and disabling read-only Health summaries. Preserve the connection and mark only nutrition exports as authorization-required when nutrition scopes are absent.
There was a problem hiding this comment.
Fixed in 5f7602a: the guard now checks connection.scopes against GOOGLE_HEALTH_NUTRITION_SCOPES before dispatching. When the scope is missing, only the export is paused (nutrition.result(..., "authorization_required")); the connection itself, its refresh token, and read-only Health summaries are untouched. Added test_worker_preserves_connection_when_nutrition_scope_missing.
| ) -> tuple[str, Exception | None]: | ||
| sequence = int(revision["sequence"]) | ||
| resource_name = str(revision["resource_name"]) | ||
| if str(revision["state"]) == "uncertain": |
There was a problem hiding this comment.
[P1] Reconcile persisted in-flight writes
_select_pending_revision explicitly says in_flight and uncertain revisions must be reconciled first, but this branch handles only uncertain. A crash after line 237 persists in_flight, and the next tick sends the same named create again without the exact get check. Treat persisted in_flight rows as uncertain before POST and cover the restart case.
There was a problem hiding this comment.
Fixed in 5f7602a: both _dispatch_upsert and _dispatch_delete now treat a persisted in_flight state the same as uncertain (GET-verify before ever re-POSTing), covering the crash-then-restart case. Added test_worker_reconciles_persisted_in_flight_upsert and test_worker_reconciles_persisted_in_flight_delete.
| return _append_google_health_sync_counts(text, result) | ||
|
|
||
|
|
||
| def _append_google_health_sync_counts(text: str, result: object) -> str: |
There was a problem hiding this comment.
[P2] Include sync counts in refresh output
This helper is not wired to the successful production path: SyncResult has no google_health_sync field, and _refresh_health drops result on success and sends only format_health_summary(summary). Therefore /health_refresh never shows the new pending, synced, failed, and authorization-required counts; the test passes only because it supplies a synthetic object. Add the counts to SyncResult and append them on the success path.
There was a problem hiding this comment.
Fixed in 5f7602a: SyncResult now carries google_health_sync (populated from storage.nutrition.counts() via a new _sync_result helper used at every return site in refresh_user/sync_user), and _refresh_health's success branch appends those counts to the summary text instead of dropping result. Added test_health_refresh_success_includes_sync_counts and a counts assertion in the existing service-level sync test.
- Preserve read-only Health connection when nutrition scopes are absent, pausing only the export instead of disabling reads - Reconcile persisted in_flight upsert/delete revisions the same way as uncertain ones, fixing duplicate dispatch after a crash - Add google_health_sync counts to SyncResult and surface them on the successful /health_refresh path, not just failure paths
What
Exports newly logged, edited, and deleted meals from private Telegram chats to Google Health's nutrition API, alongside the existing read-only summary connector.
Why
Users connected to Google Health had no way to get Blacki-logged meals into Google Health itself. This adds that as an opt-in capability gated on two new OAuth scopes, without weakening any existing privacy or local-save guarantees.
How
MealService.mutate()saves a meal locally and enqueues its export intent in the same SQLite transaction, so a local save never depends on Google's availability.NutritionExportWorkeris a background loop that dispatches due export jobs once a minute, or immediately whenwake()is called after a local save (near-instant sync without extra rate-limit exposure).get_data_pointbefore ever retrying, to avoid duplicate records.Retry-After, capped at 1 hour./disconnect_healthand account-switch flows already cancelled/paused nutrition export correctly from prior work; this PR only adds the dispatch engine and enrollment logic on top..env.example, and the LLM's nutrition/health-policy prompts are updated for the new scopes and sync-status semantics.Bugs found and fixed along the way
UPDATE(missingfat_g), so every edit for a connected private-chat user crashed.deleterevisions, whoseresource_namealways targets the old point rather than the meal's current desired state — it would misidentify a still-needed delete as superseded and skip it, leaving the old point on Google forever once the replacement create landed.Tests
pytest --cov=src— 1795 passed, 1 skipped, 100.00% coverage (repo's required floor)ruff check ./ruff format --check .— cleanmypy .— cleangoogle_health_sync: pending, and confirmed it landed in the Google Health app on a phone within seconds of the save (wake-early path)