`sync_now()` -- what workout-sync.timer runs every 15 minutes -- only ever
ingested MANUAL records. The session reader `pull_synced_workout()` was
reachable only from `_verify_phone_workout`, i.e. the interactive lock/status
UI, so a finished session earned nothing until a lock screen happened to open.
That was survivable while the phone was the only device recording sessions:
the same lock screen that demanded the workout also verified it. The Linux
build finishes sessions with no lock screen involved at all, so the credit
would simply never arrive.
`pull_all_session_records()` is the session counterpart to the existing
`pull_all_manual_records()` -- `pull_synced_workout` returns only the single
newest session for the live unlock check, which cannot backfill a week.
New type `pc_workout_verified` rather than reusing `phone_verified`: it is
machine-checked in exactly the same way (same WorkoutSession payload, same
set/rep and duration validation), but there is no phone involved, and writing
"phone_verified" into an HMAC-signed log would make the log lie about its own
provenance.
Ingestion re-derives the verdict instead of trusting the record's `succeeded`
flag -- the record arrives over the network and this is the thing handing out
unlock credit. Every rejection logs at WARNING with an actionable reason, per
the repo's no-silent-failures rule.
One real leak fixed along the way: formatting the duration with `.0f` rounded
34m59s up to "35 min", which is precisely the hidden accept bar -- the message
would have told the user the real threshold while rejecting them at it. It now
truncates, so the advertised 40 stays the only number shown.
Verified against live data: ingests the 4 real synced sessions, and a second
run ingests 0 (the write chokepoint dedups by workout_id), so the repeating
timer cannot double-credit. Suite green at 100% branch coverage, 1046 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
The previous commit dropped tombstoned records while reading each device
log, which was not enough: _merge_device_records unions across devices
keeping the highest HLC, so a record tombstoned on one device and still
live on another was simply absent from one dict and won the merge from
the other -- ingested exactly as before.
Collect tombstoned ids across every device log, then suppress by id
after the union. crdt-sync makes deletion monotonic inside its own merge
for this reason; this reader rolls its own merge, so it has to honour
the same rule itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
_workout_sync.py (332) -> _sync_client.py token, Firebase/GitHub client
_sync_records.py payload predicates + log decoding
_sick_tracker.py (317) -> _sick_budget.py SickHistory + rolling budget
This one was not a pure move until the fixtures followed it. Three autouse
test guards patch names by module, and all three pointed at _workout_sync:
isolate_sync_token SYNC_TOKEN_FILE -> now also on _sync_client
_no_real_firebase_config CONFIG_FILE -> now on _sync_client
_firebase_config helper (7 call sites) -> now passed _sync_client
With them stale, sync_client built a REAL client: the suite went from 14s to
over 120s and test_workout_sync_part5 asserted against live workout data
pulled from the actual backend. Fixed, the same tests run in 0.7s. Both
modules keep SYNC_TOKEN_FILE so either patch point works, and every relocated
guard target is asserted to exist.
977 tests pass at 100% branch coverage; pylint back to 8.70 after fixing the
findings the split introduced (a dropped `return None` in _parse_iso and
leftover dead imports). `screen_lock --status` unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
A real StrongLifts session on 2026-08-10 never reached this machine, so it
had to be logged as a manual workout -- spending a manual budget slot on a
workout that was genuinely verified. The session was in Firebase the whole
time; four separate defects each hid it.
- pull_synced_workout() read a hardcoded devices/phone/log.json. Since
ffc6c72 the phone pushes under a per-install uuid, so that directory has
been frozen since 2026-07-17 and the PC kept returning a stale session
from 07-27 as if it were current. Both readers now walk EVERY device
directory; reinstalls keep minting new ones, so no single path can be
right for long.
- A session is identified by SHAPE (it has `exercises`), not by a `kind`
field the phone never writes. That filter is why the one reader that did
walk every directory discarded real sessions, and it also kept PC-origin
runnerup/phone_verified records from being handed to a caller expecting
a session.
- FirebaseSyncError is a SIBLING of GitHubSyncError, not a subclass, so
`except GitHubSyncError` never caught it and a DNS failure took the whole
locker down (status=1/FAILURE, then a restart loop). Catch the common
parent; a pull failure degrades to a warning.
- A GitHub token is no longer required to sync. Firebase has been primary
since eb4ff01, so a Firebase-only machine was reporting "sync is OFF"
while syncing fine.
Sync also only ran once per locker start, so a workout finished after login
was invisible until the next login. workout-sync.timer runs the new headless
--sync-only every 15 minutes; remove_systemd.sh now removes every unit the
installer creates, not just two of them.
workout_app: the 2026-08-05 reinstall wiped progression because the DB seeds
factory defaults before the backup is restored, and the first write then
exported those defaults over the only off-device copy. Storage permission is
now settled before the DB opens, a defaults-only DB refuses to overwrite a
richer backup, and the three swallowed storage errors say what broke.
Sessions that exist only in Firebase/GitHub are restored into local history,
so a reinstalled app has the history it actually earned rather than starting
at whatever survived. Matched on (date, start_time), so re-syncing never
duplicates and local rows always win.
Adds a target-reps editor: progression can only ever RAISE reps, so a rep
target a defaults re-seed set wrong could not be corrected from the UI at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JS5V44jkHZiH1wC7BH5h5n
Both surfaces of this repo move together, because they share one prefix:
screen_locker (the PC, which reads what the phone logged) and workout_app (the
phone, which writes).
Firebase becomes the primary backend when it is set up, with GitHub kept as a
mirror so a device that has not moved yet is still seen. MirrorSyncClient reads
the union of both, so a workout logged against either backend still counts --
which is what lets the PC and the phone cut over on different days.
The PC's two pulls are read-only, so they need only the client swapped.
_manual_push does a real sync_log and now carries a revision cache, as does
every push in workout_app: without it each push re-uploads the whole log and
each pull re-downloads every peer's, regardless of change.
remote_client is public rather than _private because _manual_push imports it;
one decision point beats two copies that can drift.
The config file is checked before constructing anything, so an unconfigured
machine never reaches the network -- without that guard the PC's sync tests
asserted against live Firebase data instead of their own fakes, which is how
this was caught.
screen_locker: 904 tests at 100% branch coverage.
workout_app: 299 tests at 100% line coverage; pinned v0.1.0 -> v0.6.0 across
three minor versions with no API drift. Four assertions moved off `.single`,
which now finds two writes: the log, then the revision published after it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
The morning routine is started by systemd right after boot and right after
resume, so its sync ran seconds before DHCP/DNS were up. Every such run failed
outright — and said this:
Workout sync push FAILED for 6 workout(s): network error reading
screen-locker-sync/devices — a 403 here means the token lacks
contents:write on kuhyx/syncs
Both halves of that were wrong. It was not a 403, and the token was fine: the
same six workouts pushed successfully by hand minutes later on the same token,
which reports push=true on the repo. The message cost a real investigation
chasing a permissions bug that did not exist.
- _sync_retry.with_sync_retry wraps the three sync call sites in a bounded
retry (4 attempts, 2s/4s/8s backoff — ~14s, enough for the network to come
up, short enough not to drag the routine). Verified offline in a network
namespace: three retries, then an honest give-up.
- Any GitHubSyncError retries, deliberately including RepoNotFoundError.
Classifying transient vs permanent would couple us to which exception
crdt_sync chains where; a few wasted seconds on a genuinely bad token is the
cheaper mistake.
- The push failure log now reports what actually happened. The contents:write
hint survives only for RepoNotFoundError, which is the one error that
actually implicates the repo or the token.
Retry at the application layer rather than ordering the unit after
network-online.target: the same race hits on resume, where boot ordering does
not apply, and these are user units that cannot depend on a system target.
The tests' autouse no-sleep fixture patches the module-local `sleep` name
rather than `_sync_retry.time.sleep` — the latter mutates the shared time
module object and silently disabled sleep for the whole suite, breaking an
unrelated temperature timeout test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The workout log stored ONE workout per calendar day, so a second same-day
workout was silently dropped and could never earn credit.
Multiple workouts per day:
- workout_log.json: {date: entry} -> {date: [entry, ...]}. Legacy entries are
wrapped on read (_log_io.load_workout_log) so their HMACs stay valid — no
re-signing; every write normalizes the file, migrating it progressively.
- New write chokepoint (_log_mixin.write_signed_entry) APPENDS and dedups by a
stable workout_id, returning RecordResult so callers can tell a new workout
from a duplicate. Re-verifying the same workout is a no-op.
- count_weekly_workouts is anti-gaming: each VERIFIED workout counts
individually; self-reported manual entries count at most once per day. Four
real workouts in one day satisfy the week; four manual entries count as one.
- Reward: the day's first counted workout gets the base +2h; an additional
same-day VERIFIED workout gets +1h; an additional manual gets nothing. The
old "+1h when weekly_count > 4" gate is replaced — weekly volume is the
banked bonus's job and shouldn't be paid twice.
- Fixed: the unlock screen labelled the +1h "#{weekly_count - 4}", which can
render negative under the new rule. Removed the dead runnerup_manual type.
- Flutter: the manual budget counts distinct DAYS, matching the PC.
PC->phone sync (it had never worked once):
- push_pc_manuals pushed from a side-store written at log-time, so anything
logged before that feature existed could never publish. _manual_push now
derives the crdt log directly from workout_log.json — the single source of
truth — covering manual AND verified runs. The record id is the workout's own
workout_id (derived for legacy entries to match the phone's format) and the
HLC comes from the entry's own timestamp, so a re-push is byte-identical and
the repo never churns.
- The phone reads every kind (readMergedWorkoutPayloads) and lists the PC's
workouts under "SYNCED FROM PC". The manual budget still sees manual-only, so
a verified run can't consume a self-report slot.
- Settings claimed "Connected." whenever a token STRING existed. It now
verifies against the API on open, so a revoked token reports 401 instead of
showing a green lie.
Never fail silently:
- 30 swallowed exceptions across 19 files now log WARNING with the concrete
cause AND consequence — plus the token guards in _workout_sync that returned
[] in total silence, the exact bug that hid the dead sync for weeks.
- New pre-commit hook (scripts/check_silent_failures.py) fails any except that
neither re-raises nor logs loudly. ruff's S110 only catches `except: pass`;
the real pattern here was `except: log.info(); return []`. Documented in
CLAUDE.md.
Verified on real data: the Jul 13 run (pulled from its actual TCX over ADB) now
counts alongside that day's manual squash, and all three workouts are published
to kuhyx/syncs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fv8ULVt3Q67GwCFbM7doHB
Two-way sync: the PC now publishes its own manual workouts so the phone can see
them for the shared budget. Reverses the old "the PC never pushes" invariant.
- _manual_push.py (new): a small crdt-sync store of PC-originated manuals
(manual_sync_log.json, next to workout_log.json). record_pc_manual() appends
the form's entry (wrapped as the shared kind/date payload under a stable
manual: id, monotonic HLC); push_pc_manuals() runs one sync_log tick to
devices/pc/log.json. Best-effort: no token, empty store, or a 403 from a
read-only token is swallowed so the local form still works.
- _manual_workout_dialog.py: record the manual locally on form save (fast, both
hosts) — published on the next lock startup.
- screen_lock.py: push_pc_manuals() runs in the startup sync step (also retries
any manual a previous push failed to publish).
- Updated the now-false "PC only ever reads, never pushes" docstrings in
_workout_sync.py and _constants.py; noted the contents:write PAT requirement.
A PC-pushed record round-trips through the same ingestion decoder, so it is
ingestable by any device. Full suite green at 100% coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
Ingest manual/off-app workouts synced from the phone (or another device) into
workout_log.json so they count toward the weekly-minimum unlock. No phone UI
yet — fully driven by a crafted sync log.
- _workout_sync.py: pull_all_manual_records() merges every device log under the
sync prefix (phone, pc, …), keeping the highest-HLC copy per stable record id;
and the session-verify path (_latest_payload) now skips kind="manual_workout"
records so a manual can never be treated as a StrongLifts session.
- _manual_sync.py (new): reconstruct_draft() rebuilds a ManualWorkoutDraft from a
synced payload (raw inputs only — never trusting the phone's derived fields);
ingest_manual_records() re-validates with the same validate_manual_workout the
local form uses, enforces the rate budget, then HMAC-signs and writes each
under its own date. Idempotent (dedup by sync_record_id) and non-destructive
(never clobbers an existing counted day).
- _log_mixin.py: extract write_signed_entry(log_file, date, workout_data) so the
live save path and ingestion share one date-keyed signer; _load_existing_logs
now delegates to the shared _read_logs.
- screen_lock.py: run _ingest_synced_manual_workouts() at startup, beside the
RunnerUp auto-fill, before the lock-decision early-exits.
Trust: a phone can at most add a budget-limited honour-system manual_workout,
never a phone_verified one; the PC re-validates and signs on ingest. Full suite
green at 100% coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
Sync the workout log across devices using the shared crdt-sync /
crdt_sync_dart CRDT transport (same scheme as diet_guard).
- Python: new _workout_sync.py + _temperature_status_mixin.py; wire sync
into screen_lock.py / status_view.py; add the crdt-sync git dependency
and test_sync_fixtures / test_workout_sync suites.
- Dart (workout_app): new workout_sync_service.dart, github_device_auth.dart,
sync_settings.dart with tests + fakes; settings/workout screens updated.
- docs: workstream-d-shared-crdt-transport.md.
Verification: pytest 747 passed at 100% coverage; flutter analyze clean;
flutter test passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM