mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 12:47:41 +01:00
d6e8c0973f
100 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| d6e8c0973f |
Replace scheduled skips with the shared free-day pool
Fifth and last app onto freedays (~/utils/freedays). screen-locker is the one that already had this idea: scheduled_skips.json was a bare list of dates, checked first in the lock chain, that only this app could see. That is exactly what the shared pool generalises, so the chain now asks freedays and a day taken here also stands down diet-guard, wake-alarm, leetcode-guard and home-guard. Sick days are deliberately untouched. Their rolling windows (1/7d, 3/30d, 10/90d), the 120-character justification and the escalating countdown all exist to make a sick day *hard* to take. Free days are the opposite -- declared ahead, no reason, consecutive runs allowed. Merging the two policies would have had to ruin one of them, so only the storage of the frictionless half moved. This fixes a real bug in passing. is_scheduled_skip_today compared against a UTC day while add_scheduled_skip.py wrote a local one, so a skip added near midnight could name a date the chain never matched. freedays owns "what is today" for both sides now, in local time, and the disagreement cannot recur. scripts/migrate_scheduled_skips.py moves the existing dates across; it is idempotent and has a --dry-run. Already run here: the 8 real skips (2026-05-19/20/21, 05-29, 05-30, 08-17, 08-22, 08-29) are in the pool and count against 2026, leaving 27 of 35. The original file is gitignored and was left on disk untouched. add_scheduled_skip.py becomes a thin wrapper over freedays rather than being deleted, because arm.sh calls it and the name is in muscle memory. Left as-is it would have kept writing to a file nothing reads. conftest.py went over the 250-line cap, so the two fixtures that keep the suite off real user state moved to _real_state_fixtures.py. They belong together and neither subsumes the other: _isolate_home covers paths resolved from Path.home() at call time, while the freedays redirect covers paths bound into module constants at import time, which redirecting home afterwards cannot reach. requirements.txt also takes pytest-randomly 5.0.0, ruff 0.16.6 and types-requests 2.33.0.20260906, and the pylint hooks' gatelock/crdt-sync pins move off v0.7.1/v0.9.0 -- pip refuses two direct URLs for one distribution, so those stale pins would have made the hook environments unresolvable rather than merely old. 1256 tests, 100% branch coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| 1d5965b9a7 |
Close the four gaps that let a decided lock never reach the screen
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| 8f911585c1 |
Serve the lock decision, its history and its health at 127.0.0.1:8770
The only status surface was a GTK tray icon, commented out of ~/.config/i3 on 2026-08-21. So when the weekly count went wrong, nothing on the machine said so for two weeks -- the same silence that let a deleted timer job stop enforcement for thirteen days in August. Three read-only endpoints, and a React 19 UI over them: /api/status the decision, the week, and whether today has a workout /api/decisions the recorded trail, newest first /api/health timers armed, disarm marker, log and trail freshness The Why view is the one that matters: it renders the run of "weekly_minimum_met 5/5" ticks that hid this week's bug, without anyone having to reach for journalctl. /api/status also carries gaming.workout_today, which is the fact steam-backlog-enforcer will cut the daily budget on. Deliberately just the fact -- the enforcer owns the hour values, so the two repos cannot come to disagree about what a workout day is. It is derived from count_day_credits, the same rule the locker enforces with. _compliance_state_word moves from status_view (tkinter) into _status_projection, and the systemd arming check moves from scripts/check_enforcement_armed.py into screen_locker/_armed_state.py, so the server can answer "is enforcement armed" without a second copy of the logic that would be free to disagree with the gate. web/ mirrors steam-backlog-enforcer/web but tightens it: that config has no "strict" in any tsconfig, extends only tseslint recommended (no type-aware rules at all), and gates neither lint nor typecheck in pre-commit. Here all three run, on strictTypeChecked + stylisticTypeChecked, with noUncheckedIndexedAccess and exactOptionalPropertyTypes. 44 tests, 100% of statements, branches, functions and lines. typescript stays pinned at 6.0.3: 7.0.2 is out, but every published typescript-eslint -- stable, canary and rc-v8 -- declares typescript ">=4.8.4 <6.1.0". The fleet allowlist already carries that entry. The 250-line cap now covers ts/tsx. It was python/shell/dart only, which would have made web/ exempt from the gate the rest of the repo lives under -- the same omission that exempted the whole Flutter app until 2026-08-15. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fcwu78wk17FWMHbbfbyicc |
|||
| dd8a39ade5 |
Record which log entries are two recordings of one workout
The counting fix made the numbers right; the history still read as though 2026-08-24 held two gym sessions. This annotates the pairs instead of deleting one of each, for two independent reasons. Deleting is self-undoing: every re-ingest path dedups against log.json's own contents rather than a remote ledger, so _session_sync re-pulls a removed pc_workout_verified from the CRDT store on the next 15-minute tick and re-applies its shutdown credit on the way in. And mutating signed history is against the position revoke_sick_day.py already states -- the correction belongs beside the record, not on top of it. Scanning the real log found six shared credits, not the three spotted by hand: 2026-07-17, 07-27, 08-10, 08-21, 08-24, 08-28. This has been happening since July. check_ledger_agrees refuses to write a ledger whose arithmetic disagrees with count_day_credits, so the ledger cannot come to describe a rule the locker does not use. The file is gitignored with the other state JSONs -- it names workout dates. The guard's test needs a fixture restoring sys.exit. conftest's autouse _block_real_tk_and_exit patches "screen_locker.screen_lock.sys.exit", and that attribute path resolves to the global sys module, so sys.exit is stubbed process-wide for every test in the suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fcwu78wk17FWMHbbfbyicc |
|||
| 8ac7af158d |
Count one StrongLifts session once, not once per ingestion path
The locker did not fire on 2026-08-29, or for the two weeks before it. Every 30 minutes it logged: DECISION lock=no reason=weekly_minimum_met weekly=5/5 The 5 was wrong. A StrongLifts session is ingested twice -- the phone sync writes phone_verified, the PC session sync writes pc_workout_verified, both from the same WorkoutSession payload -- and workout_id is "<type>:<date>", so the two copies get different IDs and neither dedups the other away. count_weekly_workouts then counted each entry individually. Real totals against what was counted: week of 2026-08-17 5 counted 4 real week of 2026-08-24 5 counted 3 real Both weeks were waived. Nothing reported a problem, because 5/5 is exactly what a compliant week looks like. Add credit_key/count_day_credits to _weekly_check as the single rule for "does this entry earn a credit, and is it the same credit as another": the two StrongLifts ingestion paths share one slot per day, everything else stays per-entry so two runs or two manual workouts in a day still count twice. Exported, so counters share the rule instead of re-deriving it -- re-derivation is what caused this. Route _status_data._day_status through it too. It had re-derived the rule as "verified + (1 if has_manual else 0)", which disagreed with count_weekly_workouts on any day with two manual workouts; since that value feeds the status view's weekly_minimum_met explanation, the window could explain a decision the locker never made. Two existing fixtures reached their counts using two same-day phone_verified entries -- the double-count itself. They now use a gym session plus a run, which is two real workouts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fcwu78wk17FWMHbbfbyicc |
|||
| ec0946f64e |
Grade against 3.14 too, not just run on it
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint were still grading against 3.10 -- three tools judging an interpreter nothing runs any more, and ruff withholding the pyupgrade fixes that only become safe at the newer target. `pin_ci_python.py` rewrites exactly these three knobs. What the stale target was hiding: 118 sites on `datetime.timezone.utc` instead of `datetime.UTC` (UP017), two classes still written with `Generic[...]` rather than PEP 695 syntax, and ruff's stable formatter applying PEP 758 -- `except (A, B):` becomes `except A, B:`. That last one makes the source 3.14-only at the syntax level, which is what `requires-python = ">=3.14"` already promised; kuhy chose it deliberately over holding ruff a release back (2026-08-29). The PEP 695 fix needed a hand: ruff rewrote `class WidgetGroup(Generic[_W])` to `class WidgetGroup[W: tk.Misc]` but left the body annotated with the module-level `_W`, a *different* type variable -- mypy caught it as "a function returning TypeVar should receive at least one argument containing the same TypeVar". Renamed the body to the new parameter, gave `FrameGroup.__init__` the concrete `list[tk.Frame]` it always wrapped, and deleted the two now-dead module-level TypeVars. ruff, ruff-format, mypy --strict and both pylint passes clean at the new target. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr |
|||
| 5d0c4d0e3a |
Cap --restore at 23:00 so it is a reward, not an off switch
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
--restore exists so the workout bonus can push shutdown later, and it had no upper bound at all: `sudo adjust_shutdown_schedule.sh --restore 24 24 0` from any shell undid the entire ratchet, with nothing written down. It is now clamped to a ceiling and every restore is recorded. Clamped rather than refused, so the reward path keeps working: the bonus asks for up to 24 and simply lands on 23. Ceiling chosen by kuhy as today's earned state -- base is 21, live was 23. Two corrections to the previous commit, both found by reading the callers: - It tightened validation from 0-24 to 0-23 on the grounds that the error message said 0-23. That was backwards. 24 is a real, documented value: _adjust_shutdown_time_by caps at 24 to mean midnight, and day-specific-shutdown-check.sh catches it through the morning-window condition. The message was what was wrong, so the message is what changed. - The audit line does NOT go in /etc/shutdown-schedule-overrides.conf, which was the original plan. That file is parsed by day-specific-shutdown-check.sh as start|end|created|reason, and a line covering the current time makes it exit 0 and SKIP the shutdown. An audit trail written there would have suppressed the very curfew it documented. It goes to /var/log/shutdown-restore.log, and a test asserts the conf stays empty. The log write says so on stderr when it fails, per this repo's no-silent- failures rule; it is not fatal, because refusing an earned reward over an unwritable log file would be the worse failure. 29 shell cases and 1146 python tests pass. Verified in a vmbox sandbox as root against a real /etc and a real chattr: 24 clamps to 23 and is logged, the overrides conf is untouched, and 25 is still refused. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B5jMviyk15MKw1PqW1pzKr |
|||
| 2bc31cf587 |
Ratchet MORNING_END_HOUR, in the direction it actually runs
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
adjust_shutdown_schedule.sh compared only $1 and $2, so the third value passed through unchecked in both directions: the morning shutdown window could be shortened at will while the two evening hours were guarded. The fix is not the obvious one. day-specific-shutdown-check.sh shuts down while the clock is BELOW morning_end_minutes, so a LARGER MORNING_END_HOUR means a longer shutdown window -- stricter. Copying the evening hours' `-gt` comparison would have ratcheted it backwards and blessed exactly the loosening it was meant to stop. The guard is `-lt`, and the inversion is now stated in the header rather than left to be rediscovered. Two smaller fixes in the same path: validation accepted 24 while its own error message said 0-23, and the config is now unset before being sourced so an unreadable file falls back to the documented loosest defaults instead of inheriting whatever those names last held. --restore is deliberately unchanged. It looks like a bypass, and it is one, but it is load-bearing: shutdown_base.json says 21 while the live config is 23, because the workout reward pushes the schedule later THROUGH --restore. Bounding it at the base -- as was originally proposed -- would have capped the reward at 21 and broken a daily flow. Bounding it needs a policy ceiling, which is a decision, not a refactor. The script is now sourceable, with the config path overridable, so the ratchet has tests at all: 20 cases covering both directions, the source-clobber trap, and the missing/unreadable config fallbacks. A ratchet with no test is how the $3 gap survived for months. Verified in a vmbox sandbox as root against a real /etc and a real chattr (the immutable flag genuinely applied, which a tmpfs cannot do): a stricter write lands, a looser evening hour is refused, a shortened morning window is refused with the reason, and --restore still loosens. Not exercised against the host's own /etc/shutdown-schedule.conf, deliberately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B5jMviyk15MKw1PqW1pzKr |
|||
| d3e4299223 |
Stub the HMAC signature so the manual-CLI test stops measuring the host
Some checks are pending
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| 06e48a7dc2 |
Detect a Firebase credential the server rejects, not just one that fails to build
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| fa8fa208d8 |
Cover the headless dispatch and the degraded-source annotation
Some checks are pending
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Two gaps against the 100% bar. The first is mine: --log-manual-workout's dispatch branch had no test, so nothing checked that the flag's own name is stripped before the args are forwarded, or that the exit code is the logger's. The second predates this work. `_record_decision` annotates the DECISION line with any backend it could not read, which is the thing that distinguishes "weekly=0/5, measured" from "weekly=0/5, unreadable" -- the exact confusion that hid the 2026-08 outage -- and that branch was never exercised. Now both directions are: named when a source is degraded, absent when none are. test_sync_degradation.py was at the 250-line cap, so the new class lives in test_sync_degradation_part2.py. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWq9MRemX9ZWvNmnhNWUP2 |
|||
| 9131c8086e |
Log manual workouts headlessly, with the form's rules intact
The phone form was the only way to log a manual workout, so logging one depended on the phone's foreground. Another app taking focus mid-entry could interleave a field (seen: "Played 6 games 1 did nPlayed 6 games...") or drop one, which is a bad property for a record that spends budget. `--log-manual-workout` takes every field as a flag and needs no UI, no phone and no app. It is emphatically NOT a way to log a workout without evidence: the draft goes through the same `validate_manual_workout`, so a missing or too-short field refuses the whole run, writes nothing and exits non-zero with the concrete reason. Sport-specific rules survive too -- table tennis still demands its match counts, racket and balls. Routed through `ingest_manual_records` rather than writing `log.json` directly, so validation, the rolling 2/week budget, HMAC signing and workout_id dedup are the shared implementation with the sync path instead of a second copy that can drift. That is the difference from the deleted `log_manual_workout.py`, which bypassed the checks: here the budget refuses a third entry in a week exactly as the phone does, and re-running an identical command is a no-op rather than a duplicate. "Today" is UTC, matching how the budget windows compute it -- a local date near midnight would file a workout in a different window than it is counted in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWq9MRemX9ZWvNmnhNWUP2 |
|||
| 81ebccb51a |
Stop the phone counting deleted and empty manual workouts
Some checks are pending
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
The app said "manual budget exhausted for this window" (2/2w, 4/10m) while
the PC read 1/2w, 2/10m for the same records. The PC was right; two
independent Dart defects each inflated a window by one.
`_readMergedPayloads` never looked at `Record.deleted`, so the tombstoned
duplicate `manual:2026-08-21T21:00` -- the second of two logged on 08-21,
already deleted -- kept consuming a weekly slot. Suppression is collected
across the whole device union and applied after the merge, not per file:
one device tombstoning a record while another still holds it live must
delete it, and a per-file skip would just let the live copy win. This
mirrors the PC's `_tombstoned_ids`.
`countManualBudget` also counted `manual_workout:2026-08-15`, a bare
`{type, kind, date}` stub with no evidence fields. The PC refuses to ingest
those ("no workout data to ingest; skipping permanently"), so the phone was
charging budget for a workout neither device ever accepted. The filter lives
in the counter rather than the shared reader because `runnerup_verified` and
`pc_workout_verified` payloads legitimately carry no `start_time`, and the
history view must keep showing them.
Covered by a shared literal fixture both languages read, rather than two
independent round-trips: each side's own tests passed while the pair
disagreed, which is exactly how this shipped. Verified by reverting each
guard in turn and watching the twin fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWq9MRemX9ZWvNmnhNWUP2
|
|||
| 075f7bae8c |
Cover the recovery branches, restoring the 100% gate
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
The previous session reported the new code green, but measured it with --no-cov (step 1 of finish_2026_08_24.sh passes that flag), so seven branches in the recovery path had never been exercised. Coverage was 99.78%. Most of the gap was the auto-recovery itself: whether healing succeeded, whether the retry after healing succeeded, and what gets recorded when it does not. That is the code which ran in production for the first time today, and none of its outcomes were pinned. Also covers the donor search on a machine with no ~/.config, siblings whose cache is readable but holds no usable token, and the two healthy paths that must stay silent — an always-on "this may be wrong" panel is one nobody reads. Writing the UI test surfaced that collect_source_findings is never empty for a fresh tmp_path, because an empty sync is itself a finding; the stub is there so the branch under test is "nothing to report". 1115 passed, coverage back to 100%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJmPLyT8NU4DzDJPV1KEX5 |
|||
| 16ada6e5a2 |
Tell "could not check" apart from "no workout", and stop double-granting bonuses
A real 1h57m workout on 2026-08-24 synced to Firebase and the locker still locked the screen. The trigger was auth: screen_locker was the only app with no cached firebase_auth.json, so it fell back to a stale shared password and got HTTP 400 every run. The defect was what it did next — an unreadable primary source was reported as "no workout" rather than "could not check", which is the same conflation that took 2026-06-12. Recover the session by borrowing a live refresh token from a sibling app, track degraded sources as state instead of a log line, and say so on the lock screen: name the backend, the error, and how stale the data is, so a sync fault reads as a sync fault. Also fix two bugs found while finishing the recovery: restore_and_bonus.py banked `existing + bonus` with no idempotence guard while finish_2026_08_24.sh advertised "safe to re-run" — a second run silently granted the compensation hours again. Guard on a durable `shutdown_bonus_granted_for` list; the bonus map alone cannot answer the question, since a week's total mixes earned streak hours with corrections. _extra_benefits._save_state rebuilt state from four hardcoded keys, so the next weekly rollover would have dropped that guard and re-armed the double-grant. Merge onto loaded state instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJmPLyT8NU4DzDJPV1KEX5 |
|||
| 589b9e227d |
Say every reason a lock decision had, not just the first one
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
Tests / lock-popup-safety (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
The decision ladders are first-match-wins: the matching branch calls
sys.exit(0), so no later condition is ever evaluated or recorded. That made
DECISION lock=no reason=early_bird_window_active
ambiguous in the one way that matters -- it cannot say whether the workout was
also already done, or the weekly minimum already met. Reading it, the 08:30
re-check looks like it is about to lock a machine that has nothing left to
prove, and the only way to find out was to re-derive the ladder by hand.
Keep the acting reason exactly as it was: it is the branch that actually
controlled behaviour, and it stays a stable grep-able slug that can be counted
across runs. Add the conditions that also held as an "also=" extra, which
as_line() already renders as key=value. A decision with a single reason still
renders the line it always did, so the common case needs no new format.
Only side-effect-free predicates are collected. _try_auto_upgrade_* writes log
entries and does ADB/network work, and _save_early_bird_pending writes state;
running either to produce a log line would turn a reporting change into a
state mutation in the enforcer. Verified that annotating leaves log.json and
early_bird_pending.json byte-identical.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCUN9mdfJzJXqEZatHnUCD
|
|||
| 4d1d2ea64d |
Put the demo's escape hatch inside the app, the only place it can be received
Both previous escapes were unreachable, for the same underlying reason stated two different ways. A Tk key binding cannot fire, because once the workout app takes its seat grab X delivers every keystroke to that app alone. And "run touch from another terminal" cannot be done, because the app covers the screen with an override-redirect fullscreen window -- there is no other terminal to reach. An escape has to be handled INSIDE the grab, by the app itself, or it is not an escape. So --demo-escape arms a key handler in the runner: Ctrl+Shift+Q ungrabs the seat and closes the window. The grab, the override-redirect window and the handoff are all unchanged, so the demo exercises the real locking mechanism rather than a softened copy of it, and the hatch is the one deliberate way out. The handler ungrabs before closing. Destroying a window that still holds the seat can leave the pointer and keyboard grabbed with nothing left to release them, which needs exactly the reboot this hatch exists to prevent. Not Escape, which the Dart side already binds for in-app navigation; shadowing it would change the behaviour under test. Verified in both directions against the real debug bundle, each under its own Xvfb display: with the flag, the app exits on Ctrl+Shift+Q while holding the grab and the lock takes the screen back; without it, the identical keystroke does nothing and the app keeps the screen. A test asserts the production path never passes the flag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg |
|||
| c3424059b2 |
Make the demo escapable, and stop claiming the blocking was deliberate
The demo promised three escapes and delivered none: the only way out was a hard reboot. Two independent causes, and only one of them was demo-only. launch_workout_app blocks until the app exits. Called straight from a Tk button that froze the event loop, so the Escape binding and the close button could not fire -- measured: zero handlers ran during the block, and the events were never delivered, not merely delivered late. WorkoutSession drives the same phases through `after` instead, on the main thread, so grab_release and grab_input stay where Tk requires them and the loop keeps pumping (199 ticks during a run that previously managed none). The second cause survives a healthy event loop. The Flutter app takes its own gdk_seat_grab with GDK_SEAT_CAPABILITY_ALL and never releases it, so while it is up X delivers the keyboard to it and no binding of ours can fire. Correct for production, fatal for a demo, and no flag turns it off -- so the demo's escape is a file the app cannot intercept: touch ~/.cache/stop-workout-demo. The previous commit's rationale for the blocking was wrong and is corrected here rather than left to mislead. It claimed pumping Tk would let the recovery watchdog steal the screen mid-workout; in fact release() stops the watchdog first and stop() cancels its timers outright, so there were never any ticks to race with. The blocking was incidental, which is why removing it costs the production handoff nothing. Verified with the real debug bundle under Xvfb: with the app up and holding its seat grab, the event loop is alive, the escape file terminates it, and the lock takes the grab back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg |
|||
| a8b34befb5 |
Lend the lock screen's grab to the workout app, without unlocking it
The locker cannot close() to let the app take over: close() also restores VT switching, which would hand the user Ctrl+Alt+F2 and an exit from the very obligation the lock exists to enforce. Only the grab moves; the Tk window stays mapped underneath, so the screen is still covered if the app dies. The watchdog has to be stopped for the duration, and measuring it is what makes that non-negotiable. gatelock's recovery loop re-takes any grab it finds missing every 1000ms -- by design, so that pulling a cable is not an escape -- and holds_grab() is `grab_current() is not None`, which is False once another X client holds it. Verified on a private Xvfb display: a naked release is stolen back within ~1.5s, while stopping the loop first lets the app keep the screen and the locker take it back cleanly afterwards. The same harness confirms the ordering the handshake depends on: while the locker holds the global grab a second X client cannot even finish connecting (4s, no output at all), so there is no window in which the screen is uncovered. It only gets through after the release. Reaching into lock._recovery earns an SLF001 waiver rather than a public pause on LockWindow: gatelock's _recovery.py carries a monotonicity proof that no transition ever decreases the grab, and a public lend_grab() would be exactly that transition. The waiver is documented where the other four SLF001 waivers in this repo already live. start_workout blocks the Tk loop for the whole workout, which is deliberate -- pumping Tk underneath is what would let a tick steal the screen mid-set. The docstring names the cost: a monitor hotplug is not handled until the app exits and grab_input() re-covers every live output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg |
|||
| 006ef037f2 |
Supervise the workout app as the lock surface, on a deadline that holds
The supervisor hands the X grab to the Flutter app only once the app has printed WORKOUT_LOCK: ready, so the screen is covered continuously and a window that never started is never handed the machine. Enforcing that handshake needs a timeout that actually fires. readline() on a pipe blocks until a newline arrives, so polling the clock around it cannot bound the wait: against a real subprocess a 2s deadline took 30s, and the hang was then misreported as "exited before signalling ready" -- the wrong diagnostic for the wrong failure. The wait is now select-based and reads whatever bytes are available, since a half-written line would otherwise block inside readline() past the deadline just as surely. Reads accumulate before matching, because a chunk boundary can fall inside the marker and dropping it would strand a healthy app at the lock screen. Verified against real pipes, not fakes: a silent app and a partial line both time out on schedule, the ready line is still detected, and a marker split across two reads is reassembled. Split into _workout_ready for the 250-line cap. The grab handoff and the process hooks are injected as frozen dataclasses so both directions must be supplied at the call site and a test that fakes the process also fakes the wait. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg |
|||
| a48bd1334a |
Credit StrongLifts sessions from the headless sync, as pc_workout_verified
`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 |
|||
| 2c206e5070 |
Adopt crdt-sync v0.9.0's bundled sync_log signature
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
sync_log's eleven flat keywords became SyncTarget / LogCodec / RevisionTracking upstream. Call sites and test doubles follow; the hook's own pinned crdt-sync moves to v0.9.0 too, so the gate checks against the API the code actually imports rather than an older tag. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCucBhUkHBBHHbsY7bSdGV |
|||
| cc5075ba53 |
Cover _tombstoned_ids
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk |
|||
| cdee896d43 |
Apply sync tombstones across the device union, not per device log
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 |
|||
| 590f606856 |
Honour crdt-sync tombstones when ingesting synced workouts
_records_matching read every record's payload and never checked record.deleted, so a tombstoned workout was ingested like a live one. Deleting a workout in the phone app therefore looked like it worked and came back on the next 15-minute sync, silently re-crediting it. crdt-sync deletion is monotonic (a tombstone cannot be resurrected by merging an older, non-deleted copy), so this is the only durable way to retract a synced workout across devices. Skip tombstoned records and say so at info, since "this stopped counting" is exactly the kind of thing that must not happen silently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk |
|||
| 390e916922 |
Finish the RunnerUp sport-allowlist removal; isolate the instance lock in tests
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
A prior commit (
|
|||
| c1c73d43ba |
Stop the relaxed-day popup from re-firing every 30 minutes after Skip
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
Tests / lock-popup-safety (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
workout-locker.timer (added in
|
|||
| e5db25468d |
Re-arm the locker, and make a disarmed enforcer impossible to miss
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
The screen locker stopped locking on 2026-08-04 and nobody could tell. Not a
logic bug: every predicate worked the whole time. systemd logged
timers.target: Found ordering cycle: early-bird-workout-check.timer/start
after graphical-session.target/start after basic.target/start after
timers.target/start - after early-bird-workout-check.timer
timers.target: Job early-bird-workout-check.timer/start deleted to break
ordering cycle starting with timers.target/start
and left the timer disabled. A timer that is WantedBy=timers.target must not be
ordered After= a target that itself comes after timers.target; systemd resolves
that loop by deleting the job, which is an absence, and absences page nobody.
That timer is the ONLY thing that closes the early-bird window. Each morning a
run inside the window banked early_bird_pending.json, logged "will re-check at
08:30", and exited 0. The re-check never came, so the marker stood all day,
every day, for thirteen days.
It stayed invisible because logging.basicConfig was called only in the
--sync-only branch, never for --production -- the mode systemd actually runs.
Journal proof: zero INFO lines from workout-locker.service since June. Every
early exit logged at INFO into a logger with no handler. Meanwhile the healthy
15-minute sync kept the system looking alive.
The 2026-08-15 Python split is NOT implicated: it was pure code motion, and
basicConfig already sat in that branch before it.
Fixes:
* Drop the After= that closes the cycle; Persistent=true so a machine asleep at
08:30 still re-checks after resume.
* Add workout-locker.timer (*:00,30). The service is WantedBy=
graphical-session.target, i.e. one decision per login, so the 08-16 -> 08-17
day and ISO-week rollover inside one long session was never re-evaluated.
* arm.sh: install units, enable timers, verify -- one command, because
re-arming was a four-command sequence that was easy to half-do, and
`systemctl enable` returns 0 while systemd deletes the timer job.
* check_enforcement_armed.py asks systemd what it actually scheduled (enabled
AND has a next trigger) and fails loudly otherwise. Wired as ExecStartPost on
workout-sync.service, so a disarmed locker marks that unit failed every 15
minutes instead of scrolling past. install_systemd.sh now fails on it too.
Logging (the reason this went unnoticed, so treated as a defect in its own
right):
* basicConfig for every mode, never one branch.
* Every enforcement-abandoning exit records a decision instead of an invisible
INFO: one greppable line -- DECISION lock=no reason=early_bird_window_active
weekly=0/5 -- at WARNING, since per CLAUDE.md an INFO explanation of a
non-action is a silent failure with extra steps. The enforced case is logged
too: "no line at all" was the outage's whole signature.
* A durable JSONL trail outside the repo (~/.local/share/screen_locker/), so
"when did this last actually enforce?" survives journal rotation. --status
and --sync-only record lock=n/a; "did not decide" must never look like
"decided not to lock".
* check_silent_failures.py now also catches sys.exit(0) that explains nothing.
Verified both directions: it flags 3/3 exits in the pre-fix _auto_upgrade.py
and passes the repo clean.
Sync noise: four records replayed as WARNINGs every cycle (~384/day), which is
how the actionable lines got tuned out. manual_workout:2026-08-15 is an empty
stub ({date, kind, type}) -- no workout to recover, and that day's credit was
already counted under a different sync_record_id, so no credit was ever lost.
The three "collided" records are dedup working. Both now report at info; a
WARNING from this logger again means something real. 4 -> 0 per cycle.
Verified on the live system: the periodic timer fired at 16:45 and the journal
shows `DECISION lock=no reason=scheduled_skip_day weekly=0/5` -- the line that
did not exist for thirteen days. Both timers report enabled and scheduled, and
no ordering cycle recurs. Today is a scheduled skip so the machine was not
locked mid-session; enforcement resumes tomorrow 08:30. 1007 tests, 100%
coverage, all hooks green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2b6ZjBcdrkSYnMujx4mWd
|
|||
| 76a4c6a0a7 |
Finish the Python side: scripts, conftest, and the last test modules
verify_lock_popup_safety.py (318) -> _xvfb_reexec.py shared re-exec
_popup_form_check.py HostileLock, ticks,
the real-form check
verify_screen_fits.py (266) -> uses _xvfb_reexec too
conftest.py (329) -> _tk_mocks.py the tk mock + the
module patch list
_opt_in_fixtures.py requestable fixtures
three test modules -> _part4/_part5/_part15 siblings
conftest keeps every autouse fixture. pytest applies autouse fixtures from
conftest only, so one moved to a sibling would silently stop running and take
its safety guard with it -- verified by asserting the fixtures are still
listed, not just that the suite is green. The five near-identical _isolate_*
fixtures collapse into one table-driven _isolate_state_files, which also picks
up the _shutdown_sick_state binding an earlier split created.
scripts/ becomes a real package so the shared helpers are importable. Both
gates now run as `python3 -m scripts.<name>`, including CI -- the path form put
scripts/ on sys.path instead of the repo root, and the alternative was a
try/except ImportError that this repo's no-silent-failures hook rightly
rejects. Each gate was run in its exact invocation form.
Note: verify_lock_popup_safety still fails on
`RecoveryLoop.__init__() takes 3 positional arguments but 6 were given`. That
is pre-existing -- confirmed by running it at HEAD in a clean worktree before
touching it -- a gatelock API drift, not this refactor. Left as found.
978 tests at 100% branch coverage. Gate: 41 -> 22 files, all remaining ones
Dart plus DESIGN_AUDIT_TODO.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
|
|||
| 8bc981da4e |
Split _runnerup_verification, _ui_widgets and _sick_dialog
_runnerup_verification.py (312) -> _runnerup_tcx.py pull + parse TCX
_runnerup_backfill.py fill the log for a
date or a week
_ui_widgets.py (287) -> _ui_form_fields.py labelled fields,
focus, disable_paste
_sick_dialog.py (278) -> _sick_commitment.py next-day commitment
_ui_form_fields also takes disable_paste, since the fields are what use it;
_ui_widgets re-exports it for _manual_workout_widgets and _sick_dialog.
Two things had to move with the code rather than being left behind:
- conftest's _TK_MODULES gains _ui_form_fields, or its tkinter calls reach a
real display instead of the mock.
- pyproject's S314 waiver follows the ET.parse call it covers into
_runnerup_tcx.py. Same approved suppression, new home -- not a new one.
Test count 977 -> 978: _ui_form_fields is a new lock-surface module, so
test_no_popup_widgets safety-checks it too. 100% branch coverage held; pylint
back at 8.70 after fixing a genuinely undefined WidgetGroup annotation the
split exposed. `screen_lock --status` unchanged. Gate: 41 -> 28 files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
|
|||
| 094a1fe5ed |
Split _workout_sync.py and _sick_tracker.py
_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
|
|||
| f5c6c2fb9d |
Split _manual_workout_dialog.py and _manual_workout.py
_manual_workout_dialog.py (357) -> _manual_workout_sport_fields.py
ManualWorkoutSportFieldsMixin
(+ _mw_scrollable_form moved into the
existing widgets mixin, where the
generic scaffold belongs)
_manual_workout.py (355) -> _manual_workout_types.py: the sport
constants, ManualWorkoutDraft, and the
rolling budget accounting
A painting mixin cannot simply move: conftest's mock_tk fixture patches `tk`
per-module through the _TK_MODULES list, so the new module had to be
registered there or its tkinter calls reach a real display. That is what the
two dialog tests caught.
Test count 976 -> 977: _manual_workout_sport_fields is a new lock-surface
module, so test_no_popup_widgets now safety-checks it too. Coverage stays
100%, pylint holds at 8.70/10, `screen_lock --status` unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
|
|||
| bdc405c8c9 |
Split _shutdown, _phone_verification and _status_data
Six new modules, all composed or re-exported so no caller changes:
_shutdown.py (384) -> _shutdown_sick_state.py SickDayStateMixin
_wake_alarm.py WakeAlarmMixin
_phone_verification.py (380)-> _adb_transport.py AdbTransportMixin
_http_workout_fetch.py HttpWorkoutFetchMixin
_status_data.py (370) -> _status_types.py the 7 dataclasses
_status_projection.py shutdown projection
+ format_summary_line
Moving a name means moving where it is *read*, so 30-odd mock patch targets
move with it. Most importantly conftest.py's autouse _block_real_network
fixture: ruff correctly dropped the now-unused `socket` import from
_phone_verification, which silently removed the guard's target and would have
let subnet probes reach the real network. It now blocks socket in both modules
that actually own the probes, and both targets are asserted to exist.
976 tests pass at 100% branch coverage; pylint 8.70 -> 8.71; `screen_lock
--status` renders the real week unchanged. File-length gate: 41 -> 35.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
|
|||
| 43a8b1d0f6 |
Split _compliance_state.py and status_view.py
_compliance_state.py was 399 lines and status_view.py 396.
_compliance_state.py becomes three acyclic modules:
_compliance_predicates.py the five leaf predicates + the gatelock
log_integrity imports
_compliance_trace.py PredicateResult / AutoUpgradeOpportunity /
LockExplanation, _stage, and
describe_auto_upgrade_opportunity
_compliance_state.py explain_lock_decision, importing both
Two-module shapes were all cyclic here: _compliance_state must export the
engine while owning the predicates it calls. Three modules break that with no
deferred import, no forwarder and no loss of the typed signature; every name
is re-exported, so _status_data.py and _log_mixin.py are untouched.
Because the hmac helpers now live in _compliance_predicates, 29 patch targets
across 5 test modules move with them -- patching a name where it is no longer
read would silently stop taking effect.
status_view.py's seven _section_* render methods become StatusSectionsMixin in
_status_sections.py, composed into StatusWindow alongside its existing mixins.
Test count goes 975 -> 976: test_no_popup_widgets parametrizes over
PACKAGE_DIR.glob("*.py"), so _status_sections.py is now covered by the
no-popup safety check too. Coverage stays 100%, pylint holds at 8.70/10, and
`screen_lock --status` renders the real week unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
|
|||
| 3476dd3afd |
Split the last 8 test modules; scope pylint's test-inherent checks
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Splits test_screen_lock_coverage_part2 via a shared _unlock_helpers.setup_unlock factory rather than duplicating the fixture, and finishes the remaining runnerup/manual-workout/status-view test modules under the 250-line cap. Also splits the pylint hook in two. protected-access, unused-argument and duplicate-code are how pytest and unit-testing private mixins work, not defects, and a commit made entirely of test files could never clear the 8.0 batch score with them counted. Source keeps the full rule set — verified that an injected protected-access is still reported there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y |
|||
| dd8296fd3f |
Split 12 more test modules under the 250-line cap
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y |
|||
| 5c27769642 |
Split 10 more test modules under the 250-line cap
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| a0d78bc719 |
Split 13 test modules under the cap; document their test methods
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| 4b4695e23a |
Split screen_lock.py under the 250-line cap
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| c63a02f9eb |
Add sync-status card and duration-threshold fixture
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| a2ce5049f0 |
Qualify a run on EITHER duration or distance, not both
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
MIN_RUN_DURATION_MINUTES and MIN_RUN_DISTANCE_KM were an AND (30+ min AND 5.0+ km), which rejected a fast 5 km run that took under 30 minutes. They are now an OR at 40 min / 3.5 km: a run qualifies at either threshold alone. Distance keeps its 5% GPS tolerance; duration, from the device clock, does not. Fixed one stale test whose fixture (1 min / 6 km) predated the OR change: 6 km alone now qualifies under the new rule, so the fixture needed to fail BOTH branches to still exercise the "no file verified, return first result" path it was written for. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1 |
|||
| e7fa7fb44c |
Let the PC see phone workouts again, and stop losing progression on reinstall
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
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 |
|||
| b008992cb7 |
Show which sync backend the PC uses in the status window
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
The status window is the PC's surface for workout data, but it said nothing about the sync layer that data now arrives through -- so a machine that had silently fallen back to the GitHub mirror, or was configured but had never completed a push, looked identical to a healthy one. That is exactly how todo's desktop app sat on GitHub unnoticed while every other surface moved to Firebase. Adds a "Sync backend" section and a --sync CLI flag (for i3blocks, alongside --summary/--state), both rendering one line: which backend this machine is configured for, when it last pushed, and how many peers it has merged. Deliberately on-disk only -- no RTDB read, no Firebase sign-in -- because gather_status() runs on every i3blocks tick and every window open; a network round-trip there would be rate-limited nonsense. Everything shown is knowable from the revision cache plus which credential files exist. Coloured by `healthy` (configured AND has pushed) rather than by backend, because "configured but never pushed" is the failure that looks fine in every settings screen and produces no data. Named _section_sync_backend, not _section_sync: another session is adding a _section_sync that reports the outcome of a background sync *tick*. The two are complementary -- did the tick run, versus what is this machine wired to -- so they coexist rather than one overwriting the other. Verified by running it, not just the suite: the real window shows "firebase · pushed 2026-08-09 21:06 · 2 peer(s)" against this machine's live state, and `--sync` prints the same line. 924 tests, 100% branch coverage. Every except logs at warning per the repo's no-silent-failures hook; no suppressions added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9 |
|||
| 87e625cc5f |
Make the phone's manual-workout budget agree with the PC, twice over
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
The phone and PC each compute a nominally SHARED manual-workout budget over
the same merged records, and disagreed on both halves of it.
The cap: _constants.py has said 10 per 30 days since 298daf0; the Dart mirror
was still 5, so between 5 and 9 the phone hard-blocked the form for a workout
the PC would have accepted and credited. The stricter side wins, so this cost
logged workouts rather than granting extra ones.
The counting rule: count_in_window counts ENTRIES ("each logged manual workout
consumes its own budget slot"), while countManualBudget built a Set of date
strings and counted DAYS -- despite a docstring claiming it mirrored the PC.
Three workouts on one day read 3/2 on the PC, over its 7-day cap, but 1/2 on
the phone, which kept accepting. Both now report 3 for that input. Per-entry is
the correct semantic because each entry separately earns weekly-count and
shutdown credit, so each must separately cost budget.
The wire fixture could never have caught either: the caps and the counting rule
never travel on the wire. The comment claiming it was "the tripwire that
catches drift" was wrong and is corrected. The real guard,
TestCrossLanguageBudgetConstants, parses manual_workout.dart and compares it to
the imported Python constants -- one test that fails on EITHER side's drift.
Asserting a Python constant against a Python literal would have stayed green
through
|
|||
| ffc6c72df0 |
Push under a persisted per-install uuid, not a fixed role id
screen_locker pushed manual workouts as "pc" and the workout app as "phone". A role constant collides the moment a second machine takes the same role, and after a reinstall the new install inherits the previous one's CRDT identity. Both halves now mint a uuid once and persist it: the PC in ~/.local/share/screen_locker/.device_id, the app under SharedPreferences 'crdt.nodeId' -- the same key every sibling app uses. The old constant survives as the legacy id and is passed to sync_log / syncLog, so devices/<legacy>/ is skipped as this device's own rather than re-downloaded and re-merged as a peer's on every tick. It drops to None once that path is reclaimed. Note the *read* loop in _workout_sync deliberately still merges every device including this one: the PC needs its own manual workouts in the merged view, so "skip own" would be wrong there. Only the push path and the HLC node id move. The test fixture redirecting the new id file is added in the same change that introduces the constant -- without it the first test that syncs mints a uuid into the real state dir and this machine's live sync identity is decided by whichever test ran first. Verified: ~/.local/share/screen_locker/ held no .device_id after a full suite run. Verified against the live backend, not just mocks: ran the real push_pc_workouts, which minted 57582b3b and pushed 9 records. In the RTDB the new device's log is a strict superset of both old paths -- 0 of devices/pc/'s 10 records and 0 of devices/phone/'s 9 are missing from it. Python: 907 tests, 100% branch coverage. workout_app: 313 tests, analyzer clean. Pins bumped to crdt-sync-v0.6.0 / crdt_sync_dart-v0.7.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP |
|||
| eb4ff01e04 |
Sync workouts through Firebase, mirroring to GitHub during the cutover
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
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 |
|||
| a79c4fdd05 |
Stop the lock screen scrolling itself, and make every screen fit 768px
This morning the lock screen scrolled to the bottom and back with nobody
touching the keyboard, on the 1366x768 panel. Reproduced under Xvfb with the
real app: one programmatic focus_set() produced three scrolls,
frac 0.000 -> 0.436 -> 0.145. The cause is in gatelock (fixed in v0.4.0,
pinned here): the viewport followed every <FocusIn>, and this app focuses a
widget on every repaint. It now follows only focus the *user* moved, and a
screen that fits cannot scroll at all.
The other half is this app's own layout, because scrolling was covering for
screens that did not fit. The manual-workout form needed 1643px of a 768px
screen -- SUBMIT and BACK were simply off the bottom:
* Every font size and pad now goes through the design-system tokens rather
than a literal. A positive size means *points* to Tk, so "36" rendered
~37% larger than the 36px intended; that alone was most of the overflow,
on all 88 call sites.
* The form's cells put their label beside the field instead of above it, and
the three reflection boxes share rows instead of taking one each.
Result, measured by the new scripts/verify_screen_fits.py (a pre-commit hook
now, so this cannot come back quietly): the form is 752px of 768px, and every
other screen -- including heat-skip, which had no viewport at all -- fits with
room to spare.
Also fixed, all found by measuring rather than by reading: content that fits
is centred again (the reset-to-top was cancelling it on every repaint), the
scrollbar only appears when there is something to scroll, and unlock_screen
moved to _unlock_view so screen_lock.py stays under the 400-line cap.
Tests: 896 pass. The focus/scroll helpers shipped in
|
|||
| 3e77f4e232 |
Make every lock screen keyboard-operable, and add the missing installer
The locker was not running at all. workout-locker.service crash-looped ~1977
times in a single boot with ModuleNotFoundError while remaining `enabled`, and
has no successful start in the retained journal. A locker that cannot start is
a locker that does not lock.
Root cause: commit
|
|||
| c0aae7d05b |
Make the lock overlay's sport selector usable, and focus its form fields
The manual-workout sport selector was a `tk.OptionMenu`, and while the screen
was locked it could not be changed. A posted Tk menu is a separate
override-redirect toplevel that takes the Tk grab for itself, and gatelock's
1s recovery tick attacks it from both sides: `_surfaces.enforce()` lifts the
lock surface over the menu, and `_reassert_grab()` read the grab sitting on
the menu as "the grab was lost" and yanked it back. The menu opened on click
and died within a second. On 2026-07-26 that forced an hour-long walk to be
logged through the table-tennis form, with the reason typed into `racket`.
Radio buttons instead. They are ordinary children of the surface, so they
lift with it and live inside the root's grab tree -- neither mechanism can
reach them. That is a structural guarantee, not a tuned workaround; the
alternative, teaching gatelock to skip its lift while a popup is posted,
would open a window where something could cover the lock. They are also the
first version of this control styled from the shared palette, where the
OptionMenu was default grey on the dark overlay.
Nothing on a lock surface may open a popup, so two halves enforce it. The
static half walks each module's AST for popup-creating names -- AST rather
than a source regex because the modules explain this bug in prose that names
the very widgets being banned. Its module list is derived ("imports tkinter",
minus a short reasoned exemption list) rather than hardcoded, so a new lock
screen is in scope the day it is written; a hardcoded list drafted today
would already have missed `_sick_dialog`. The behavioural half is
`scripts/verify_lock_popup_safety.py`: it builds the real form with real Tk,
replays both hostile behaviours for five ticks, and re-runs the original
failure so the mechanism stays demonstrated rather than described. It
re-execs itself under Xvfb -- a global grab on the developer's own session
would freeze it -- and a CI job runs it, because the pytest suite replaces
the whole `tk` module and so cannot host it.
Separately, no field ever took keyboard focus. Under a global grab that means
typing goes nowhere with no other window on screen to reveal why. The
manual-workout and sick-justification forms now each name their own first
field via a `focus=` flag; `on_focus_ready` genuinely has nothing to focus
(it fires before those screens exist) and its docstring now says that instead
of claiming the lock window has no typed input at all.
`scripts/fix_2026_07_26_sport.py` re-files that walk as sport "other",
rebuilt through the form's own draft/validate/build path so it carries
exactly the writer's key set, and re-signed. `start_time` and the date are
untouched so `workout_id` stays stable and the phone converges instead of
gaining a duplicate. Kept as the audit trail for the edit; the walk's details
come from the entry and `--details`, so nothing personal lands in a tracked
file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|||
| 600d0fd084 |
Migrate screen_locker to gatelock v0.2.0 per-output locking
The lock now paints one container per live output instead of one window on the primary, and claims the screen through an Arbiter at RANK_SCREEN_LOCKER so the wake alarm can outrank it. The fan-out happens at the widget factory, not the render loop. Painting a screen here is not a pure function -- the ~20 clear_container() flows record workouts, credit weeks and branch on state while they paint -- so re-running a flow per surface would double those side effects. Instead `_label`, `_button_row` and friends build N copies and return a group that mirrors later configure calls to all of them, and a flow body still runs once. Frame groups nest, so `_button(button_row, ...)` reads exactly as it did. The degenerate case carries as much weight as the general one: the status window, the post-sick-day verify window and the relaxed-day prompt have no lock and no surfaces, and share this same factory. They get a group of exactly one frame and behave as they always did. Text boxes are the one thing that cannot be shared. Entries fan out over a single StringVar, but tk.Text has no such variable, so the sick-day description genuinely diverges per monitor; TextGroup.get() returns whichever copy was actually typed into, preserving "answer on any monitor". Tests: the suite hung rather than failed on first run, which is the trap gatelock's own conftest documents -- screen_locker patches its own `tk`, but LockWindow builds Toplevels through gatelock's, and real tkinter over a MagicMock root recurses without bound. `_gatelock_fixtures` closes that and redirects the runtime dir so no test can stand a production locker down. Every other test runs single-output, where fan-out is unobservable, so test_surface_fanout runs on two outputs; each assertion was checked by cropping the group loops to the primary and confirming it goes red. 874 tests, 100% branch coverage, pre-commit clean, no lint suppressions. |