Two things the pool needed to be real rather than merely correct.
**freedays no longer depends on gatelock.** It imported
gatelock.log_integrity for ~15 lines of HMAC, but importing gatelock runs
its __init__, which imports tkinter for the lock-window machinery. So a
headless policy library had acquired a hard dependency on a GUI toolkit,
and `import freedays` failed anywhere without Tk:
ImportError: libtk8.6.so: cannot open shared object file
A vmbox run caught it. The host masked it completely -- Tk is installed
here for the lockers -- and so did CI, whose own comment claimed "no
display needed" while the job would have needed libtk. freedays/_hmac.py
now does the signing with hashlib and hmac.
The format is deliberately unchanged (HMAC-SHA256 over compact
sorted-key JSON), so nothing has to be re-signed: all 8 entries already
in the real audit trail, written by gatelock's implementation, verify
against the new one. That was checked, not assumed.
This also dissolves the version diamond that shaped the app rollout:
freedays now pins only crdt-sync, so it can never conflict with whatever
gatelock tag an app carries.
**The pool now syncs on a schedule.** A `sync-quiet` subcommand plus a
systemd user timer every 15 minutes, because "it syncs" was otherwise
only true if you typed `freedays sync` yourself -- a day marked on the
phone would sit there. sync-quiet exits 0 when there is no network or no
credential, on purpose: a unit that goes red every time the laptop is
offline is a unit you learn to ignore, and a real bug still reaches the
journal through the library's logging.
install.sh gives freedays its own venv rather than borrowing an app's --
the pool is shared by five apps, so binding its CLI to one of them would
make "is freedays installed?" depend on something unrelated, and
uninstalling that app would take the command with it.
Verified in vmbox first (systemd), then on this host: the timer is
enabled and scheduled, and the service *finishes* rather than failing
when there is no remote.
seed_session.py gains freedays, without which the one command that can
give the pool a Firebase session would reject it outright (--app is
choices-validated) and the pool would stay local-only with nothing
saying why. That file was already exactly at the 250-line cap, so the
app list and its rationale move to tool/_seeded_apps.py.
freedays: 109 tests, 100% branch coverage. crdt-sync: 433 pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|---|---|---|
| .. | ||
| crdt_sync | ||
| tool | ||
| .pre-commit-config.yaml | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
| requirements.txt | ||
crdt-sync
Shared CRDT merge scheme + GitHub-Contents-API sync transport, extracted for reuse across four personal apps that each need cross-device sync: todo, diet_guard, wake_alarm, and screen-locker.
Why
Two of those apps (diet_guard, and independently todo) had already converged
on the same shape of solution: treat a private GitHub repo as dumb file
storage via the REST Contents API (not a git clone), have each device push
its own file, and merge on read. diet_guard's merge logic
(entry-id-keyed, tombstone-wins) and todo's merge logic (sqlite_crdt's
per-field HLC last-writer-wins) solve the same underlying problem —
convergent merge without a central server — for two different data shapes
(append-only logs vs. mutable per-field records). crdt-sync generalizes
both into one scheme so all four apps can share it, at the cost of todo's
notes and wake_alarm's single wake-time value both running through
CRDT machinery their own data doesn't strictly need — an explicit
consistency-over-leanness tradeoff.
The merge scheme
A generic LWW-map-with-sticky-remove, built from three pieces:
Hlc— a Hybrid Logical Clock:(wall_time_ms, counter, node_id), totally ordered by tuple comparison (node_idbreaks ties between two clocks with identical wall time and counter, so no two distinct devices' clocks ever compare equal).Hlc.new_tick(node_id, previous)advances off wall-clock time, or bumpscounterwhen the wall clock hasn't moved pastprevious— the standard HLC construction, guaranteeing monotonicity even across clock skew or two ticks in the same millisecond.Record—id, afieldsmap ofname -> (value, Hlc), and a stickydeletedflag.merge_fieldkeeps whichever side'sHlcis greater, per field — this is what lets two devices concurrently edit different fields of the same record (or the same field) and converge deterministically without a central authority.merge_record'sdeletedis not itself per-field LWW: it'sa.deleted or b.deleted, monotonic and one-directional. This preserves a guarantee diet_guard's original tombstone scheme already relied on — a delete can never be silently undone by merging in an older non-deleted copy pulled from a device that hasn't seen the delete yet.Log=dict[id, Record].merge_logstakes the union of ids andmerge_records the ones present on both sides.
All three merges are commutative and idempotent (provable directly from
max-by-Hlc and boolean-or), so pull order between devices never matters
and a repeated sync tick is a no-op — see crdt_sync/tests/test_log.py's
property-style cases for the concrete proof.
Per-app adapters are out of scope for this library. Translating a Note,
a FoodEntry, a workout entry, or wake_alarm's {hour, minute} value to and
from Record is each app's own job — keeping this library domain-agnostic is
what makes it actually shared, rather than shaped around one app's schema.
Install
pip install "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.2.0#subdirectory=crdt-sync"
Usage
from crdt_sync import GitHubSyncClient, Hlc, Record, sync_log
node_id = "pc"
clock = Hlc.new_tick(node_id)
record = Record(id="abc123", fields={"text": ("buy milk", clock)}, deleted=False)
client = GitHubSyncClient(owner="kuhyx", repo="my-app-sync", token=token)
result = sync_log(
client=client,
device_id=node_id,
path_prefix="devices",
local_log={record.id: record},
encode=lambda log: json.dumps({k: r.to_dict() for k, r in log.items()}),
decode=lambda text: {
k: Record.from_dict(v) for k, v in json.loads(text).items()
},
)
sync_log pulls every other device's last-pushed log from
<path_prefix>/<other-device-id>/..., merges each into the local log with
merge_logs, then pushes this device's own merged result back up — the same
pull-all/merge/push-own pattern diet_guard's _sync.py already used, made
domain-agnostic via the encode/decode callbacks so callers keep their own
on-disk JSON shape instead of crdt-sync dictating one.
Local persistence
For persisting a Log to disk, crdt_sync ships load/save helpers that share
a canonical on-disk JSON shape with the Dart crdt_sync_dart.LogStore, so a log
written by one language parses in the other:
from crdt_sync import dump_log, load_log, read_log, write_log
write_log(path, log) # atomic temp-file-then-rename
log = read_log(path) # empty on a missing or corrupt file
text = dump_log(log) # -> canonical JSON string
log = load_log(text) # <- parse it back
These are load/save only. The Dart side additionally offers a reactive
LogStore (a change stream to drive a live UI); that's a Flutter-only
convenience and has no Python equivalent by design — Python consumers here are
headless sync ticks.
Development
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pytest
pre-commit install && pre-commit install --hook-type pre-push