diff --git a/sickdays/README.md b/sickdays/README.md index 257f5c3..827be8c 100644 --- a/sickdays/README.md +++ b/sickdays/README.md @@ -24,20 +24,48 @@ raises**. A missing file means "no sick days". An unreadable or corrupt file is logged as a warning and also reads as *not sick*: it fails closed, so the alarm rings and the locks apply. +### Keeping some lockers on a sick day (0.2.0) + +A sick day may **keep** some lockers active. A locker *stands down* on a day +iff the day is sick **and** the locker is not in that day's keep set. A gate +that is one of the lockers asks the narrower question: + +```python +if sickdays.stands_down(sickdays.LOCKER_LEETCODE): # default: today + ... # this locker is off today +``` + +Locker ids (`LOCKER_*` constants): `workout`, `leetcode`, `book`, `diet`, +`alarm`. `alarm` is one switch for the phone ring, the PC fallback ring, the +RTC self-wake and the missed-alarm penalty. + +`LOCKERS` is all five, in that order. `stands_down` never raises for a data +problem (only `ValueError` for an id not in `LOCKERS`). Every doubt answers +"the locks apply": an unreadable pool is not sick, a malformed keep set keeps +every locker. `is_sick_day` is unchanged, so a gate still on the 0.1.0 API +keeps working and treats every sick day as "everything stands down". + ## For a person ```console $ sickdays add # today $ sickdays add 2026-10-20 # or 'tomorrow' +$ sickdays add tomorrow --keep leetcode --keep workout # those two stay on +$ sickdays keep 2026-10-20 diet # replace the kept set of a sick day +$ sickdays keep 2026-10-20 # keep nothing: every locker stands down again $ sickdays remove 2026-10-20 # today or later only $ sickdays list # every sick day, oldest first +2026-10-16 (today) +2026-10-20 keep: diet $ sickdays check tomorrow # exit 0 if sick, 1 if not -- for scripts +$ sickdays check --locker alarm # exit 0 iff that locker stands down today $ sickdays sync # pull and push now ``` `python -m sickdays ...` is the same command. Exit codes: `0` done, `1` refused (past day, already sick, not sick, a free day, sync unavailable), -`2` input not understood. +`2` input not understood (a bad date, or a locker id not in `LOCKERS` -- +`--keep`, `keep` and `--locker` only accept the five ids). ## API @@ -45,12 +73,19 @@ refused (past day, already sick, not sick, a free day, sync unavailable), |---|---| | `is_sick_day(day=None, *, log_path=None, now=None) -> bool` | the gate question; never raises | | `sick_days(*, log_path=None) -> frozenset[date]` | every date currently sick | -| `add_sick_day(day, *, is_free=None, paths=None, now=None) -> SickDay` | today or later; refuses a free day | +| `stands_down(locker, day=None, *, log_path=None, now=None) -> bool` | the per-locker gate question; never raises for data, `ValueError` for an unknown id | +| `kept_lockers(day=None, *, log_path=None, now=None) -> frozenset[str]` | the day's kept lockers; empty when not sick | +| `add_sick_day(day, *, keep=(), is_free=None, paths=None, now=None) -> SickDay` | today or later; refuses a free day; `ValueError` on an unknown locker | +| `set_kept_lockers(day, keep, *, paths=None, now=None) -> SickDay` | replace an existing sick day's kept set; today or later | | `remove_sick_day(day, *, paths=None, now=None) -> SickDay` | today or later | | `import_legacy(days, *, paths=None) -> list[date]` | migration only: may write past dates; idempotent | | `ensure_not_sick(day, *, paths=None)` | for freedays' callers, before `freedays.mark` | | `sync()`, `sync_quietly()` | one pull/merge/push tick | +`SickDay` is `(day, is_sick, actor="", keep=frozenset())`. The locker ids are +`LOCKER_WORKOUT`, `LOCKER_LEETCODE`, `LOCKER_BOOK`, `LOCKER_DIET`, +`LOCKER_ALARM` and the tuple `LOCKERS`. + Refusals subclass `SickDayError`: `PastDateError`, `AlreadySickError`, `NotSickError`, `FreeDayConflictError`, `SickDayConflictError`, `ConflictCheckUnavailableError`, `UnreadablePoolError`. Writers refuse an @@ -81,7 +116,7 @@ import cycle: |---|---| | the pool | `~/.local/share/sickdays/sick_days.json` | | device uuid (per install) | `~/.local/share/sickdays/device_id` | -| audit trail (HMAC-signed, append-only) | `~/.local/share/sickdays/audit.jsonl` + `hmac.key` | +| audit trail (HMAC-signed, append-only; actions `add`, `remove`, `keep`, `import`) | `~/.local/share/sickdays/audit.jsonl` + `hmac.key` | | sync bookkeeping (local only) | `~/.local/share/sickdays/sync_state.json` | Days are local calendar dates (`_day.today()`), the same as freedays. @@ -110,7 +145,8 @@ The log is a JSON object keyed by ISO date. Each value is a crdt-sync record: "id": "2026-10-20", "fields": { "state": ["sick", "2026-10-16T08:12:03.517Z-0000-"], - "actor": ["", "2026-10-16T08:12:03.517Z-0000-"] + "actor": ["", "2026-10-16T08:12:03.517Z-0000-"], + "keep": [["leetcode", "workout"], "2026-10-16T08:12:03.517Z-0000-"] }, "deleted": false, "deleted_hlc": null @@ -134,11 +170,48 @@ The log is a JSON object keyed by ISO date. Each value is a crdt-sync record: crdt-sync OR-merges tombstones, so a deleted date could never be marked sick again. A tombstoned record reads as "not sick". +#### `keep` (0.2.0) + +`keep` is `[, ]`. The list is **sorted** +(plain string order) and **de-duplicated**, always: the rev is a SHA-256 of +the exact JSON string, so two devices holding the same set must write the +same bytes. Ids: `workout`, `leetcode`, `book`, `diet`, `alarm`. + +Writing: + +- **add** (mark sick) writes `state: "sick"`, `actor` and `keep` (default + `[]`), all three with the **same** HLC tick, always. So a stale `keep` from + another device cannot survive a clear and a re-add. +- **remove** writes `state: "cleared"`, `actor` and `keep: []`, same tick. +- **change kept lockers** on an existing sick day **does not touch `state`**: + copy the record, stamp only `keep` and `actor` with one fresh tick, and + leave `state` (and its HLC) as it was. Re-stamping `state` would make a + keep edit re-assert "sick" over a concurrent clear. Only today or later, + only on a day that is currently sick. +- Every new tick is issued after both this device's last tick and the + greatest HLC already on that day's record, so an edit never comes out + older than the `state` it was made against. + +Reading (a locker *stands down* iff the day is sick and the locker is not in +the keep set), checked in this order: + +1. Day not sick (no record, tombstone, state not `"sick"`, unreadable pool) + -> nothing stands down; `keep` is not looked at. +2. No `keep` field (every 0.1.0 record) -> keep nothing: every locker stands + down. +3. `keep`'s HLC is **older** than `state`'s HLC -> stale (an old 0.1.0 + client re-added the day) -> keep nothing. Equal HLCs are not stale. +4. `keep`'s value is not a list, or holds a non-string element -> malformed + -> fail closed: **every** locker is kept (nothing stands down), and a + warning is logged. (A stale malformed value is just stale: rule 3 wins.) +5. Otherwise the kept set is the list, with unknown string ids **ignored** + (forward compatibility), not an error. + Don't hand-roll this on Dart: use `crdt_sync_dart` (`syncLog`, `logToJson` / `logFromJson`), exactly as wake-alarm's `free_day_sync.dart` does for `freedays-sync`. Only the prefix (`sickdays-sync/devices`), the filename (`sick_days.json`) and the `"sick"` state value differ. There is no -`consumed` or `reason` field. +`consumed` or `reason` field. The phone must follow the `keep` rules above. ## Install diff --git a/sickdays/pyproject.toml b/sickdays/pyproject.toml index 3047b4d..fae7c55 100644 --- a/sickdays/pyproject.toml +++ b/sickdays/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sickdays" -version = "0.1.0" +version = "0.2.0" description = "Shared sick-day pool: per-date, uncapped, synced; every gate respects it" requires-python = ">=3.14" # Deliberately NOT gatelock (its __init__ imports tkinter); the HMAC lives in diff --git a/sickdays/sickdays/__init__.py b/sickdays/sickdays/__init__.py index 4a1c418..c75e58d 100644 --- a/sickdays/sickdays/__init__.py +++ b/sickdays/sickdays/__init__.py @@ -12,6 +12,12 @@ It reads one local file, never the network, and never raises: an unreadable pool is logged and reads as "not sick", so the failure mode is "the normal rules apply", never "everything is switched off". +Since 0.2.0 a sick day may keep some lockers active, so a gate that is one +of :data:`LOCKERS` asks the narrower question instead:: + + if sickdays.stands_down(sickdays.LOCKER_LEETCODE): + ... # this locker is off today + Sick days are not free days. They are a separate pool, with a separate file and a separate remote node, and a date is never in both (:mod:`sickdays._conflict`). There is no cap and no justification: every day @@ -35,6 +41,14 @@ from sickdays._conflict import ( ensure_not_sick, free_day_predicate, ) +from sickdays._constants import ( + LOCKER_ALARM, + LOCKER_BOOK, + LOCKER_DIET, + LOCKER_LEETCODE, + LOCKER_WORKOUT, + LOCKERS, +) from sickdays._day import parse_iso, resolve, to_iso, today from sickdays._errors import ( AlreadySickError, @@ -46,11 +60,18 @@ from sickdays._errors import ( SickDayError, UnreadablePoolError, ) +from sickdays._keep_api import kept_lockers, set_kept_lockers, stands_down from sickdays._model import SickDay from sickdays._paths import Paths from sickdays._sync import SyncUnavailableError, sync, sync_quietly __all__ = [ + "LOCKERS", + "LOCKER_ALARM", + "LOCKER_BOOK", + "LOCKER_DIET", + "LOCKER_LEETCODE", + "LOCKER_WORKOUT", "AlreadySickError", "ConflictCheckUnavailableError", "FreeDayConflictError", @@ -70,11 +91,14 @@ __all__ = [ "free_day_predicate", "import_legacy", "is_sick_day", + "kept_lockers", "load", "parse_iso", "remove_sick_day", "resolve", + "set_kept_lockers", "sick_days", + "stands_down", "sync", "sync_quietly", "to_iso", diff --git a/sickdays/sickdays/_api.py b/sickdays/sickdays/_api.py index 4281efe..004b69c 100644 --- a/sickdays/sickdays/_api.py +++ b/sickdays/sickdays/_api.py @@ -15,21 +15,17 @@ from __future__ import annotations from typing import TYPE_CHECKING -from crdt_sync import Hlc, load_device_identity +from crdt_sync import load_device_identity from sickdays._audit import record_event from sickdays._conflict import ensure_not_free, free_day_predicate from sickdays._day import to_iso, today -from sickdays._errors import AlreadySickError, NotSickError, PastDateError -from sickdays._model import ( - SickDay, - build_record, - is_sick_record, - latest_hlc, - sick_dates, -) +from sickdays._errors import AlreadySickError, NotSickError +from sickdays._keep import normalise_keep +from sickdays._model import SickDay, is_sick_record, sick_dates from sickdays._paths import Paths, resolve_paths -from sickdays._store import read_pool, read_pool_strict, write_pool +from sickdays._store import read_pool, read_pool_strict +from sickdays._writes import refuse_past, write_entry if TYPE_CHECKING: from collections.abc import Iterable @@ -68,25 +64,10 @@ def sick_days(*, log_path: Path | None = None) -> frozenset[date]: return sick_dates(load(log_path)) -def _refuse_past(day: date, reference: date, verb: str) -> None: - if day < reference: - msg = ( - f"{to_iso(day)} has already passed -- sick days can only be " - f"{verb} for today or later" - ) - raise PastDateError(msg) - - -def _write(log: Log, entry: SickDay, resolved: Paths) -> None: - """Stamp ``entry`` with this node's next tick and persist the pool.""" - tick = Hlc.new_tick(entry.actor, latest_hlc(log, entry.actor)) - log[to_iso(entry.day)] = build_record(entry, tick) - write_pool(resolved.log, log) - - def add_sick_day( day: date, *, + keep: Iterable[str] = (), is_free: FreePredicate | None = None, paths: Paths | None = None, now: date | None = None, @@ -95,6 +76,8 @@ def add_sick_day( Args: day: The day. Today or later. + keep: Lockers that stay active on the day (:data:`sickdays.LOCKERS`); + every other locker stands down. Default: none -- all stand down. is_free: "Is this a free day?"; ``None`` asks freedays, imported lazily (:func:`sickdays._conflict.free_day_predicate`). paths: Where the pool, uuid and audit trail live. @@ -104,23 +87,25 @@ def add_sick_day( The stored entry. Raises: + ValueError: ``keep`` names an unknown locker. PastDateError: ``day`` has already passed. AlreadySickError: ``day`` is already a sick day. FreeDayConflictError: ``day`` is a free day. ConflictCheckUnavailableError: The free-day pool cannot be checked. UnreadablePoolError: The local pool exists but cannot be parsed. """ + kept = normalise_keep(keep) resolved = resolve_paths(paths) - _refuse_past(day, today(now), "added") + refuse_past(day, today(now), "added") log = read_pool_strict(resolved.log) existing = log.get(to_iso(day)) if existing is not None and is_sick_record(existing): msg = f"{to_iso(day)} is already a sick day" raise AlreadySickError(msg) ensure_not_free(day, is_free if is_free is not None else free_day_predicate()) - entry = SickDay(day=day, is_sick=True, actor=actor_id(resolved)) - _write(log, entry, resolved) - record_event("add", day, actor=entry.actor, paths=resolved) + entry = SickDay(day=day, is_sick=True, actor=actor_id(resolved), keep=kept) + write_entry(log, entry, resolved) + record_event("add", day, actor=entry.actor, keep=kept, paths=resolved) return entry @@ -138,14 +123,14 @@ def remove_sick_day( UnreadablePoolError: The local pool exists but cannot be parsed. """ resolved = resolve_paths(paths) - _refuse_past(day, today(now), "removed") + refuse_past(day, today(now), "removed") log = read_pool_strict(resolved.log) existing = log.get(to_iso(day)) if existing is None or not is_sick_record(existing): msg = f"{to_iso(day)} is not a sick day" raise NotSickError(msg) entry = SickDay(day=day, is_sick=False, actor=actor_id(resolved)) - _write(log, entry, resolved) + write_entry(log, entry, resolved) record_event("remove", day, actor=entry.actor, paths=resolved) return entry @@ -172,7 +157,7 @@ def import_legacy(days: Iterable[date], *, paths: Paths | None = None) -> list[d existing = log.get(to_iso(day)) if existing is not None and is_sick_record(existing): continue - _write(log, SickDay(day=day, is_sick=True, actor=node), resolved) + write_entry(log, SickDay(day=day, is_sick=True, actor=node), resolved) record_event("import", day, actor=node, paths=resolved) written.append(day) return written diff --git a/sickdays/sickdays/_audit.py b/sickdays/sickdays/_audit.py index bb995d9..0049784 100644 --- a/sickdays/sickdays/_audit.py +++ b/sickdays/sickdays/_audit.py @@ -1,5 +1,5 @@ # Copyright (c) 2026 Krzysztof Rudnicki -"""Append-only, HMAC-signed trail of every add, remove and legacy import. +"""Append-only, HMAC-signed trail of every add, remove, keep edit and import. Nothing reads this to make a decision -- the CRDT log is the truth. It exists so "when did I mark that day, and from which device" is answerable @@ -32,14 +32,17 @@ def record_event( day: date, *, actor: str, + keep: frozenset[str] | None = None, paths: Paths | None = None, ) -> None: """Append one signed line describing a change to the pool. Args: - action: ``"add"``, ``"remove"`` or ``"import"``. + action: ``"add"``, ``"remove"``, ``"keep"`` or ``"import"``. day: The day that changed. actor: The device id that made the change. + keep: The day's kept lockers after the change, recorded (sorted) + for an add or a keep edit; omitted from the line when ``None``. paths: Where the trail and its signing key live. """ resolved = resolve_paths(paths) @@ -49,6 +52,8 @@ def record_event( "actor": actor, "at": datetime.now(tz=UTC).isoformat(), } + if keep is not None: + entry["keep"] = sorted(keep) key_target = resolved.hmac_key if not key_target.exists(): # First run on this machine: generating the key here means the trail diff --git a/sickdays/sickdays/_cli.py b/sickdays/sickdays/_cli.py index e8877b8..8634adc 100644 --- a/sickdays/sickdays/_cli.py +++ b/sickdays/sickdays/_cli.py @@ -5,6 +5,10 @@ Every day is a normal day until marked here (or on the phone). There is no budget and no reason to give; the one rule is the calendar -- today or later only -- plus the cross-pool check: a free day cannot also be a sick day. +A sick day stands every locker down unless it *keeps* some (``--keep`` on +``add``, or ``keep`` afterwards). Locker ids are argparse ``choices``, so a +typo exits 2 instead of silently keeping nothing. + ``sync-quiet`` is what the timer runs: it reports success even with no network, because a unit that goes red whenever the laptop is offline is a unit you learn to ignore. @@ -16,19 +20,28 @@ import argparse import sys from typing import TYPE_CHECKING -from sickdays._api import add_sick_day, is_sick_day, remove_sick_day, sick_days +from sickdays._api import add_sick_day, is_sick_day, load, remove_sick_day from sickdays._cli_output import emit, emit_error +from sickdays._constants import LOCKERS from sickdays._day import resolve, to_iso, today from sickdays._errors import SickDayError +from sickdays._keep_api import set_kept_lockers, stands_down +from sickdays._model import sick_entries from sickdays._sync import SyncUnavailableError, sync, sync_quietly if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Callable, Sequence _EXIT_OK = 0 _EXIT_REFUSED = 1 _EXIT_BAD_INPUT = 2 _DAY_HELP = "YYYY-MM-DD, or 'today'/'tomorrow' (default: today)" +_LOCKER_METAVAR = "LOCKER" + + +def _keep_suffix(keep: frozenset[str]) -> str: + """``" keep: a, b"`` for a non-empty keep set, else nothing.""" + return f" keep: {', '.join(sorted(keep))}" if keep else "" def _build_parser() -> argparse.ArgumentParser: @@ -42,13 +55,42 @@ def _build_parser() -> argparse.ArgumentParser: sub = parser.add_subparsers(dest="command", required=True) add = sub.add_parser("add", help="mark a day sick (today or later)") add.add_argument("day", nargs="?", default="today", help=_DAY_HELP) + add.add_argument( + "--keep", + action="append", + default=[], + choices=LOCKERS, + metavar=_LOCKER_METAVAR, + help=f"keep this locker active (repeatable; one of {', '.join(LOCKERS)})", + ) + keep = sub.add_parser( + "keep", help="replace the lockers an existing sick day keeps active" + ) + keep.add_argument("day", help="YYYY-MM-DD, or 'today'/'tomorrow'") + keep.add_argument( + "lockers", + nargs="*", + choices=LOCKERS, + metavar=_LOCKER_METAVAR, + help="lockers to keep; none stands every locker down again", + ) remove = sub.add_parser("remove", help="make a day normal again") remove.add_argument("day", nargs="?", default="today", help=_DAY_HELP) sub.add_parser("list", help="every sick day, oldest first") check = sub.add_parser( - "check", help="exit 0 if the day is sick, 1 if not (for scripts)" + "check", + help=( + "exit 0 if the day is sick (with --locker: if that locker stands " + "down), 1 if not -- for scripts" + ), ) check.add_argument("day", nargs="?", default="today", help=_DAY_HELP) + check.add_argument( + "--locker", + choices=LOCKERS, + metavar=_LOCKER_METAVAR, + help="instead: exit 0 iff this locker stands down on the day, 1 if not", + ) sub.add_parser("sync", help="pull and push the pool now") sub.add_parser( "sync-quiet", @@ -57,9 +99,18 @@ def _build_parser() -> argparse.ArgumentParser: return parser -def _do_add(raw_day: str) -> int: - entry = add_sick_day(resolve(raw_day)) - emit(f"{to_iso(entry.day)} is now a sick day") +def _do_add(raw_day: str, keep: list[str]) -> int: + entry = add_sick_day(resolve(raw_day), keep=keep) + emit(f"{to_iso(entry.day)} is now a sick day{_keep_suffix(entry.keep)}") + return _EXIT_OK + + +def _do_keep(raw_day: str, lockers: list[str]) -> int: + entry = set_kept_lockers(resolve(raw_day), lockers) + if entry.keep: + emit(f"{to_iso(entry.day)}{_keep_suffix(entry.keep)}") + else: + emit(f"{to_iso(entry.day)}: every locker stands down") return _EXIT_OK @@ -70,18 +121,23 @@ def _do_remove(raw_day: str) -> int: def _do_list() -> int: - days = sorted(sick_days()) - if not days: + entries = sick_entries(load()) + if not entries: emit("no sick days") return _EXIT_OK reference = today() - for day in days: - emit(f"{to_iso(day)}{' (today)' if day == reference else ''}") + for entry in entries: + marker = " (today)" if entry.day == reference else "" + emit(f"{to_iso(entry.day)}{marker}{_keep_suffix(entry.keep)}") return _EXIT_OK -def _do_check(raw_day: str) -> int: +def _do_check(raw_day: str, locker: str | None) -> int: day = resolve(raw_day) + if locker is not None: + off = stands_down(locker, day) + emit(f"{to_iso(day)}: {locker} {'stands down' if off else 'applies'}") + return _EXIT_OK if off else _EXIT_REFUSED sick = is_sick_day(day) emit(f"{to_iso(day)}: {'sick day' if sick else 'normal day'}") return _EXIT_OK if sick else _EXIT_REFUSED @@ -98,18 +154,15 @@ def _do_sync_quiet() -> int: return _EXIT_OK -def _dispatch(args: argparse.Namespace) -> int: - if args.command == "add": - return _do_add(args.day) - if args.command == "remove": - return _do_remove(args.day) - if args.command == "list": - return _do_list() - if args.command == "check": - return _do_check(args.day) - if args.command == "sync": - return _do_sync() - return _do_sync_quiet() +_HANDLERS: dict[str, Callable[[argparse.Namespace], int]] = { + "add": lambda args: _do_add(args.day, args.keep), + "keep": lambda args: _do_keep(args.day, args.lockers), + "remove": lambda args: _do_remove(args.day), + "list": lambda _args: _do_list(), + "check": lambda args: _do_check(args.day, args.locker), + "sync": lambda _args: _do_sync(), + "sync-quiet": lambda _args: _do_sync_quiet(), +} def main(argv: Sequence[str] | None = None) -> int: @@ -121,7 +174,7 @@ def main(argv: Sequence[str] | None = None) -> int: """ args = _build_parser().parse_args(argv) try: - return _dispatch(args) + return _HANDLERS[args.command](args) except ValueError as exc: emit_error(f"error: {exc}") return _EXIT_BAD_INPUT diff --git a/sickdays/sickdays/_constants.py b/sickdays/sickdays/_constants.py index 408f095..e75b07e 100644 --- a/sickdays/sickdays/_constants.py +++ b/sickdays/sickdays/_constants.py @@ -48,9 +48,30 @@ SYNC_FILENAME: Final = "sick_days.json" #: Field names inside a Record. FIELD_STATE: Final = "state" FIELD_ACTOR: Final = "actor" +#: The sorted, de-duplicated list of locker ids that stay active on the day +#: (0.2.0). Absent on every 0.1.0 record, which reads as "keep nothing". +FIELD_KEEP: Final = "keep" #: Values of FIELD_STATE. Removing flips the field rather than tombstoning #: the record: crdt-sync deletes are monotonic (OR-merged), so a tombstoned #: date could never be marked sick again. STATE_SICK: Final = "sick" STATE_CLEARED: Final = "cleared" + +#: Locker ids a sick day may keep active (``FIELD_KEEP``). A locker *stands +#: down* on a day iff the day is sick and the locker is not in its keep set. +#: These strings are wire format: the phone writes the same ones. +LOCKER_WORKOUT: Final = "workout" +LOCKER_LEETCODE: Final = "leetcode" +LOCKER_BOOK: Final = "book" +LOCKER_DIET: Final = "diet" +#: The phone ring, the PC fallback ring, the RTC self-wake and the +#: missed-alarm penalty -- one switch, because they are one alarm. +LOCKER_ALARM: Final = "alarm" +LOCKERS: Final = ( + LOCKER_WORKOUT, + LOCKER_LEETCODE, + LOCKER_BOOK, + LOCKER_DIET, + LOCKER_ALARM, +) diff --git a/sickdays/sickdays/_keep.py b/sickdays/sickdays/_keep.py new file mode 100644 index 0000000..6670e56 --- /dev/null +++ b/sickdays/sickdays/_keep.py @@ -0,0 +1,110 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""The per-day keep set: which lockers stay active on a sick day. + +The keep set is one more last-writer-wins field on the day's record +(``FIELD_KEEP``), holding a **sorted, de-duplicated** JSON list of locker +ids. Sorting is wire format, not tidiness: the RTDB rev is a SHA-256 of the +exact serialised string, so two devices holding the same set must write the +same bytes. + +Reading is where the safety lives, and each rule is pinned by a test: + +- no ``keep`` field (every 0.1.0 record) -- keep nothing: every locker + stands down, exactly as before 0.2.0; +- a ``keep`` stamped *older* than ``state`` -- stale (an old client re-added + the day without writing it), so keep nothing; +- a ``keep`` that is not a list of strings -- fail closed: every locker is + kept, i.e. the locks apply, and a warning is logged; +- an id this version does not know, inside an otherwise valid list -- ignored, + so a newer phone can add a locker without breaking this reader. + +Staleness is decided first: a superseded value is ignored whatever it holds. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from crdt_sync import Record + +from sickdays._constants import FIELD_ACTOR, FIELD_KEEP, FIELD_STATE, LOCKERS + +if TYPE_CHECKING: + from collections.abc import Iterable + + from crdt_sync import Hlc + +_logger = logging.getLogger(__name__) + +_ALL: frozenset[str] = frozenset(LOCKERS) + + +def validate_locker(locker: str) -> str: + """Return ``locker`` if it is a known id. + + Raises: + ValueError: ``locker`` is not one of :data:`sickdays.LOCKERS`. + """ + if locker not in _ALL: + msg = f"unknown locker {locker!r}; expected one of {', '.join(LOCKERS)}" + raise ValueError(msg) + return locker + + +def normalise_keep(keep: Iterable[str]) -> frozenset[str]: + """Validate every id in ``keep`` and return them as a set. + + A bare string is refused rather than iterated character by character. + + Raises: + ValueError: An id is not one of :data:`sickdays.LOCKERS`. + """ + if isinstance(keep, str): + msg = f"keep must be a collection of locker ids, not the string {keep!r}" + raise ValueError(msg) + return frozenset(validate_locker(locker) for locker in keep) + + +def keep_wire(keep: frozenset[str]) -> list[str]: + """The on-the-wire value of ``keep``: sorted, so the bytes are stable.""" + return sorted(keep) + + +def read_keep(record: Record) -> frozenset[str]: + """Which known lockers ``record``'s day keeps active (the rules above). + + Only meaningful while the day is sick; callers check that first. + """ + field = record.fields.get(FIELD_KEEP) + if field is None: + return frozenset() + value, hlc = field + state = record.fields.get(FIELD_STATE) + if state is not None and hlc < state[1]: + return frozenset() + if not isinstance(value, list) or not all(isinstance(v, str) for v in value): + _logger.warning( + "sick day %s has a malformed keep field %r -- every locker applies", + record.id, + value, + ) + return _ALL + return frozenset(value) & _ALL + + +def with_keep(record: Record, keep: frozenset[str], *, actor: str, tick: Hlc) -> Record: + """Return ``record`` with only ``keep`` and ``actor`` re-stamped ``tick``. + + ``state`` keeps its own clock on purpose: re-stamping it would make a + keep edit re-assert "sick" over a concurrent clear from another device. + """ + fields = dict(record.fields) + fields[FIELD_KEEP] = (keep_wire(keep), tick) + fields[FIELD_ACTOR] = (actor, tick) + return Record( + id=record.id, + fields=fields, + deleted=record.deleted, + deleted_hlc=record.deleted_hlc, + ) diff --git a/sickdays/sickdays/_keep_api.py b/sickdays/sickdays/_keep_api.py new file mode 100644 index 0000000..677434f --- /dev/null +++ b/sickdays/sickdays/_keep_api.py @@ -0,0 +1,108 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Per-locker questions and the keep edit (0.2.0). + +A sick day may keep some lockers active. A locker *stands down* on a day iff +the day is sick **and** the locker is not in that day's keep set. +:func:`stands_down` is what a gate asks; like :func:`sickdays.is_sick_day` it +reads one local file and never raises for a data problem. An unreadable pool +reads as "not sick" and a malformed keep set as "every locker kept" -- both +mean the locks apply. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sickdays._api import actor_id, load +from sickdays._audit import record_event +from sickdays._day import to_iso, today +from sickdays._errors import NotSickError +from sickdays._keep import normalise_keep, read_keep, validate_locker +from sickdays._model import SickDay, is_sick_record +from sickdays._paths import resolve_paths +from sickdays._store import read_pool_strict +from sickdays._writes import refuse_past, write_keep + +if TYPE_CHECKING: + from collections.abc import Iterable + from datetime import date + from pathlib import Path + + from sickdays._paths import Paths + + +def kept_lockers( + day: date | None = None, + *, + log_path: Path | None = None, + now: date | None = None, +) -> frozenset[str]: + """The lockers ``day`` (default: today) keeps active; empty when not sick. + + Never raises: a malformed keep set is logged and reads as every locker. + """ + target = day if day is not None else today(now) + record = load(log_path).get(to_iso(target)) + if record is None or not is_sick_record(record): + return frozenset() + return read_keep(record) + + +def stands_down( + locker: str, + day: date | None = None, + *, + log_path: Path | None = None, + now: date | None = None, +) -> bool: + """Whether ``locker`` is switched off on ``day`` (default: today). + + True iff the day is sick and ``locker`` is not in its keep set. Never + raises for a data problem -- every one of them answers ``False``. + + Raises: + ValueError: ``locker`` is not one of :data:`sickdays.LOCKERS` (a + programmer error, not a data problem). + """ + validate_locker(locker) + target = day if day is not None else today(now) + record = load(log_path).get(to_iso(target)) + if record is None or not is_sick_record(record): + return False + return locker not in read_keep(record) + + +def set_kept_lockers( + day: date, + keep: Iterable[str], + *, + paths: Paths | None = None, + now: date | None = None, +) -> SickDay: + """Replace the kept lockers of an existing sick day. Today or later only. + + Only ``keep`` and ``actor`` are re-stamped; ``state`` keeps its clock, + so this edit can never re-assert "sick" over a concurrent clear. An empty + ``keep`` stands every locker down again. + + Returns: + The stored entry. + + Raises: + ValueError: ``keep`` names an unknown locker. + PastDateError: ``day`` has already passed. + NotSickError: ``day`` is not a sick day. + UnreadablePoolError: The local pool exists but cannot be parsed. + """ + kept = normalise_keep(keep) + resolved = resolve_paths(paths) + refuse_past(day, today(now), "changed") + log = read_pool_strict(resolved.log) + existing = log.get(to_iso(day)) + if existing is None or not is_sick_record(existing): + msg = f"{to_iso(day)} is not a sick day" + raise NotSickError(msg) + actor = actor_id(resolved) + write_keep(log, existing, kept, actor=actor, resolved=resolved) + record_event("keep", day, actor=actor, keep=kept, paths=resolved) + return SickDay(day=day, is_sick=True, actor=actor, keep=kept) diff --git a/sickdays/sickdays/_model.py b/sickdays/sickdays/_model.py index 5ca128f..103b82a 100644 --- a/sickdays/sickdays/_model.py +++ b/sickdays/sickdays/_model.py @@ -21,8 +21,15 @@ from typing import TYPE_CHECKING from crdt_sync import Hlc, Record -from sickdays._constants import FIELD_ACTOR, FIELD_STATE, STATE_CLEARED, STATE_SICK +from sickdays._constants import ( + FIELD_ACTOR, + FIELD_KEEP, + FIELD_STATE, + STATE_CLEARED, + STATE_SICK, +) from sickdays._day import parse_iso, to_iso +from sickdays._keep import keep_wire, read_keep if TYPE_CHECKING: from datetime import date @@ -38,11 +45,13 @@ class SickDay: day: The calendar date. is_sick: Whether the day is currently a sick day. actor: Which device last changed it. + keep: The lockers that stay active on the day (empty unless sick). """ day: date is_sick: bool actor: str = "" + keep: frozenset[str] = frozenset() def _field_str(record: Record, name: str) -> str: @@ -70,21 +79,34 @@ def to_sick_day(record: Record) -> SickDay | None: day = parse_iso(record.id) except ValueError: return None + sick = is_sick_record(record) return SickDay( day=day, - is_sick=is_sick_record(record), + is_sick=sick, actor=_field_str(record, FIELD_ACTOR), + keep=read_keep(record) if sick else frozenset(), + ) + + +def sick_entries(log: Log) -> list[SickDay]: + """Every day ``log`` currently marks sick, oldest first (junk ids skipped).""" + entries = (to_sick_day(record) for record in log.values()) + return sorted( + (entry for entry in entries if entry is not None and entry.is_sick), + key=lambda entry: entry.day, ) def sick_dates(log: Log) -> frozenset[date]: """Every date ``log`` currently marks sick (junk ids skipped).""" - days: set[date] = set() - for record in log.values(): - entry = to_sick_day(record) - if entry is not None and entry.is_sick: - days.add(entry.day) - return frozenset(days) + return frozenset(entry.day for entry in sick_entries(log)) + + +def record_hlc(record: Record | None) -> Hlc | None: + """Return the greatest clock on any field of ``record`` (any node).""" + if record is None or not record.fields: + return None + return max(hlc for _, hlc in record.fields.values()) def latest_hlc(log: Log, node_id: str) -> Hlc | None: @@ -108,13 +130,16 @@ def build_record(entry: SickDay, hlc: Hlc) -> Record: One tick for every field, so a concurrent add and remove of one day resolve together rather than interleaving state from one device and the - actor from the other. + actor from the other. ``keep`` is always written -- ``[]`` on a clear -- + so a keep set from before a clear can never survive a later re-add. """ state = STATE_SICK if entry.is_sick else STATE_CLEARED + keep = keep_wire(entry.keep) if entry.is_sick else [] return Record( id=to_iso(entry.day), fields={ FIELD_STATE: (state, hlc), FIELD_ACTOR: (entry.actor, hlc), + FIELD_KEEP: (keep, hlc), }, ) diff --git a/sickdays/sickdays/_writes.py b/sickdays/sickdays/_writes.py new file mode 100644 index 0000000..55a83f8 --- /dev/null +++ b/sickdays/sickdays/_writes.py @@ -0,0 +1,71 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""The two ways a write reaches the pool: a whole record, or one field update. + +A whole-record write (add, remove, import) stamps every field with one tick. +A keep edit must not: it re-stamps only ``keep`` and ``actor``, leaving +``state`` on its own clock (:func:`sickdays._keep.with_keep`). + +Every tick is issued *after* both this node's last tick and every clock +already on the day's record, so a write always beats what it was looking at, +even when another device's wall clock runs ahead of this one's. Without that, +a keep edit made under clock skew could be born older than ``state`` -- and a +keep older than ``state`` reads as stale. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from crdt_sync import Hlc + +from sickdays._day import to_iso +from sickdays._errors import PastDateError +from sickdays._keep import with_keep +from sickdays._model import build_record, latest_hlc, record_hlc +from sickdays._store import write_pool + +if TYPE_CHECKING: + from datetime import date + + from crdt_sync import Log, Record + + from sickdays._model import SickDay + from sickdays._paths import Paths + + +def refuse_past(day: date, reference: date, verb: str) -> None: + """Raise :class:`PastDateError` when ``day`` is before ``reference``.""" + if day < reference: + msg = ( + f"{to_iso(day)} has already passed -- sick days can only be " + f"{verb} for today or later" + ) + raise PastDateError(msg) + + +def next_tick(log: Log, actor: str, existing: Record | None) -> Hlc: + """This node's next clock, later than its own and ``existing``'s ticks.""" + seen = [t for t in (latest_hlc(log, actor), record_hlc(existing)) if t is not None] + return Hlc.new_tick(actor, max(seen) if seen else None) + + +def write_entry(log: Log, entry: SickDay, resolved: Paths) -> None: + """Replace the day's whole record with ``entry`` and persist the pool.""" + key = to_iso(entry.day) + tick = next_tick(log, entry.actor, log.get(key)) + log[key] = build_record(entry, tick) + write_pool(resolved.log, log) + + +def write_keep( + log: Log, + existing: Record, + keep: frozenset[str], + *, + actor: str, + resolved: Paths, +) -> None: + """Re-stamp only ``keep`` and ``actor`` on ``existing`` and persist.""" + tick = next_tick(log, actor, existing) + log[existing.id] = with_keep(existing, keep, actor=actor, tick=tick) + write_pool(resolved.log, log) diff --git a/sickdays/sickdays/tests/conftest.py b/sickdays/sickdays/tests/conftest.py index c0bd774..f0f6dde 100644 --- a/sickdays/sickdays/tests/conftest.py +++ b/sickdays/sickdays/tests/conftest.py @@ -22,7 +22,13 @@ if TYPE_CHECKING: from datetime import date from pathlib import Path -_MODULES = ("sickdays._api", "sickdays._audit", "sickdays._conflict", "sickdays._sync") +_MODULES = ( + "sickdays._api", + "sickdays._audit", + "sickdays._conflict", + "sickdays._keep_api", + "sickdays._sync", +) def _never_free(_day: date) -> bool: diff --git a/sickdays/sickdays/tests/test_cli.py b/sickdays/sickdays/tests/test_cli.py index 836ba25..0be2e07 100644 --- a/sickdays/sickdays/tests/test_cli.py +++ b/sickdays/sickdays/tests/test_cli.py @@ -115,3 +115,56 @@ def test_python_dash_m_runs_the_cli( runpy.run_module("sickdays", run_name="__main__") assert exited.value.code == 0 assert "no sick days" in capsys.readouterr().out + + +def test_add_keeps_lockers_and_list_shows_them( + capsys: pytest.CaptureFixture[str], +) -> None: + assert main(["add", "today"]) == 0 + assert main(["add", _future(), "--keep", "workout", "--keep", "leetcode"]) == 0 + assert "sick day keep: leetcode, workout" in capsys.readouterr().out + assert main(["list"]) == 0 + assert capsys.readouterr().out.splitlines() == [ + f"{to_iso(today())} (today)", + f"{_future()} keep: leetcode, workout", + ] + + +def test_an_unknown_locker_exits_two(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exited: + main(["add", "--keep", "leetcod"]) + assert exited.value.code == 2 + assert "invalid choice" in capsys.readouterr().err + with pytest.raises(SystemExit) as exited: + main(["keep", _future(), "nope"]) + assert exited.value.code == 2 + + +def test_keep_replaces_the_set(capsys: pytest.CaptureFixture[str]) -> None: + main(["add", _future(), "--keep", "book"]) + assert main(["keep", _future(), "diet", "alarm"]) == 0 + assert f"{_future()} keep: alarm, diet" in capsys.readouterr().out + assert main(["check", _future(), "--locker", "book"]) == 0 + assert "book stands down" in capsys.readouterr().out + assert main(["check", _future(), "--locker", "diet"]) == 1 + assert "diet applies" in capsys.readouterr().out + assert main(["keep", _future()]) == 0 + assert "every locker stands down" in capsys.readouterr().out + assert main(["check", _future(), "--locker", "diet"]) == 0 + + +def test_keep_on_a_normal_or_past_day_is_refused( + capsys: pytest.CaptureFixture[str], +) -> None: + assert main(["keep", _future(), "book"]) == 1 + assert "not a sick day" in capsys.readouterr().err + yesterday = to_iso(today() - timedelta(days=1)) + assert main(["keep", yesterday]) == 1 + assert "already passed" in capsys.readouterr().err + + +def test_check_with_a_locker_on_a_normal_day_exits_one( + capsys: pytest.CaptureFixture[str], +) -> None: + assert main(["check", "--locker", "alarm"]) == 1 + assert "alarm applies" in capsys.readouterr().out diff --git a/sickdays/sickdays/tests/test_keep.py b/sickdays/sickdays/tests/test_keep.py new file mode 100644 index 0000000..eaa692f --- /dev/null +++ b/sickdays/sickdays/tests/test_keep.py @@ -0,0 +1,132 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Reading a day's keep set: each wire-format rule, pinned.""" + +from __future__ import annotations + +from datetime import date +import logging +from typing import TYPE_CHECKING + +from crdt_sync import Hlc, Record +import pytest + +from sickdays._constants import LOCKERS +from sickdays._keep import ( + keep_wire, + normalise_keep, + read_keep, + validate_locker, + with_keep, +) +from sickdays._model import SickDay, build_record, record_hlc, to_sick_day + +if TYPE_CHECKING: + from typing import Any + +DAY = date(2026, 10, 20) + + +def _tick(wall_ms: int, node: str = "pc") -> Hlc: + return Hlc(wall_time_ms=wall_ms, counter=0, node_id=node) + + +def _sick(keep: tuple[Any, Hlc] | None, state_at: int = 5) -> Record: + fields: dict[str, tuple[Any, Hlc]] = { + "state": ("sick", _tick(state_at)), + "actor": ("pc", _tick(state_at)), + } + if keep is not None: + fields["keep"] = keep + return Record(id="2026-10-20", fields=fields) + + +def test_a_0_1_0_record_without_keep_keeps_nothing() -> None: + assert read_keep(_sick(None)) == frozenset() + + +def test_a_keep_older_than_state_is_stale() -> None: + assert read_keep(_sick((["leetcode"], _tick(4)))) == frozenset() + + +def test_a_keep_from_the_same_tick_counts() -> None: + assert read_keep(_sick((["leetcode"], _tick(5)))) == {"leetcode"} + + +def test_a_later_keep_counts() -> None: + assert read_keep(_sick((["book", "diet"], _tick(9)))) == {"book", "diet"} + + +@pytest.mark.parametrize("value", ["leetcode", {"leetcode": True}, ["diet", 7], None]) +def test_a_malformed_keep_fails_closed_to_every_locker( + value: object, caplog: pytest.LogCaptureFixture +) -> None: + with caplog.at_level(logging.WARNING): + assert read_keep(_sick((value, _tick(5)))) == frozenset(LOCKERS) + assert "malformed keep field" in caplog.text + + +def test_a_stale_malformed_keep_is_still_just_stale() -> None: + assert read_keep(_sick(("junk", _tick(1)))) == frozenset() + + +def test_unknown_ids_are_ignored() -> None: + record = _sick((["hologram", "leetcode"], _tick(5))) + assert read_keep(record) == {"leetcode"} + + +def test_a_keep_without_a_state_is_read_as_is() -> None: + record = Record(id="2026-10-20", fields={"keep": (["book"], _tick(1))}) + assert read_keep(record) == {"book"} + + +def test_validate_locker_names_the_choices() -> None: + assert validate_locker("alarm") == "alarm" + with pytest.raises(ValueError, match="workout, leetcode, book, diet, alarm"): + validate_locker("leetcod") + + +def test_normalise_deduplicates_and_refuses_junk() -> None: + assert normalise_keep(["diet", "diet", "book"]) == {"book", "diet"} + assert normalise_keep(()) == frozenset() + with pytest.raises(ValueError, match="not the string"): + normalise_keep("diet") + with pytest.raises(ValueError, match="unknown locker"): + normalise_keep(["diet", "nope"]) + + +def test_the_wire_value_is_sorted() -> None: + assert keep_wire(frozenset({"workout", "alarm", "leetcode"})) == [ + "alarm", + "leetcode", + "workout", + ] + + +def test_an_add_writes_keep_on_the_state_tick() -> None: + entry = SickDay(DAY, is_sick=True, actor="pc", keep=frozenset({"diet", "book"})) + record = build_record(entry, _tick(3)) + assert record.fields["keep"] == (["book", "diet"], _tick(3)) + assert record.fields["state"] == ("sick", _tick(3)) + assert to_sick_day(record) == entry + + +def test_a_clear_writes_an_empty_keep_on_the_state_tick() -> None: + entry = SickDay(DAY, is_sick=False, actor="pc", keep=frozenset({"diet"})) + record = build_record(entry, _tick(3)) + assert record.fields["keep"] == ([], _tick(3)) + assert to_sick_day(record) == SickDay(DAY, is_sick=False, actor="pc") + + +def test_with_keep_restamps_only_keep_and_actor() -> None: + record = _sick((["book"], _tick(5))) + edited = with_keep(record, frozenset({"alarm"}), actor="phone", tick=_tick(8)) + assert edited.fields["state"] == ("sick", _tick(5)) + assert edited.fields["keep"] == (["alarm"], _tick(8)) + assert edited.fields["actor"] == ("phone", _tick(8)) + assert record.fields["keep"] == (["book"], _tick(5)) + + +def test_record_hlc_is_the_greatest_field_clock() -> None: + assert record_hlc(None) is None + assert record_hlc(Record(id="x", fields={})) is None + assert record_hlc(_sick((["book"], _tick(9, "phone")))) == _tick(9, "phone") diff --git a/sickdays/sickdays/tests/test_keep_api.py b/sickdays/sickdays/tests/test_keep_api.py new file mode 100644 index 0000000..ce32448 --- /dev/null +++ b/sickdays/sickdays/tests/test_keep_api.py @@ -0,0 +1,202 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""stands_down, kept_lockers, add --keep and the keep edit, end to end.""" + +from __future__ import annotations + +from datetime import date +import json +from typing import TYPE_CHECKING + +from crdt_sync import Hlc, Record, merge_logs +import pytest + +from sickdays._api import add_sick_day, is_sick_day, load, remove_sick_day +from sickdays._constants import LOCKERS +from sickdays._errors import NotSickError, PastDateError +from sickdays._keep_api import kept_lockers, set_kept_lockers, stands_down +from sickdays._store import write_pool + +if TYPE_CHECKING: + from pathlib import Path + +TODAY = date(2026, 10, 16) +TOMORROW = date(2026, 10, 17) +KEY = "2026-10-17" + +#: A record exactly as 0.1.0 wrote the live 2026-10-16..20 days: no keep. +V010_RECORD = { + "id": KEY, + "fields": { + "state": ["sick", "2026-10-16T08:12:03.517Z-0000-phone"], + "actor": ["phone", "2026-10-16T08:12:03.517Z-0000-phone"], + }, + "deleted": False, + "deleted_hlc": None, +} + + +def _pool(state_dir: Path) -> Path: + return state_dir / "sick_days.json" + + +def _raw(state_dir: Path, record: dict[str, object]) -> None: + _pool(state_dir).write_text(json.dumps({KEY: record}), encoding="utf-8") + + +def test_a_normal_day_stands_nothing_down() -> None: + assert not any(stands_down(locker, TOMORROW) for locker in LOCKERS) + assert kept_lockers(TOMORROW) == frozenset() + + +def test_a_sick_day_with_no_keep_stands_everything_down() -> None: + add_sick_day(TOMORROW, now=TODAY) + assert all(stands_down(locker, TOMORROW) for locker in LOCKERS) + assert kept_lockers(TOMORROW) == frozenset() + + +def test_a_kept_locker_applies_and_the_rest_stand_down() -> None: + entry = add_sick_day(TOMORROW, keep=["leetcode", "leetcode"], now=TODAY) + assert entry.keep == {"leetcode"} + assert not stands_down("leetcode", TOMORROW) + assert stands_down("workout", TOMORROW) + assert kept_lockers(TOMORROW) == {"leetcode"} + assert is_sick_day(TOMORROW) + + +def test_today_is_the_default_day() -> None: + add_sick_day(TODAY, keep=["diet"], now=TODAY) + assert stands_down("book", now=TODAY) + assert not stands_down("diet", now=TODAY) + assert kept_lockers(now=TODAY) == {"diet"} + + +def test_the_add_writes_a_sorted_keep_on_one_tick(state_dir: Path) -> None: + add_sick_day(TOMORROW, keep=["workout", "alarm"], now=TODAY) + fields = json.loads(_pool(state_dir).read_text(encoding="utf-8"))[KEY]["fields"] + assert fields["keep"][0] == ["alarm", "workout"] + assert fields["keep"][1] == fields["state"][1] == fields["actor"][1] + + +def test_an_unknown_locker_on_add_writes_nothing(state_dir: Path) -> None: + with pytest.raises(ValueError, match="unknown locker"): + add_sick_day(TOMORROW, keep=["leetcod"], now=TODAY) + assert not _pool(state_dir).exists() + + +def test_an_unknown_locker_question_is_a_programmer_error() -> None: + with pytest.raises(ValueError, match="unknown locker"): + stands_down("leetcod", TOMORROW) + + +def test_a_0_1_0_record_stands_every_locker_down(state_dir: Path) -> None: + _raw(state_dir, V010_RECORD) + assert all(stands_down(locker, TOMORROW) for locker in LOCKERS) + assert kept_lockers(TOMORROW) == frozenset() + + +def test_a_malformed_keep_means_the_locks_apply(state_dir: Path) -> None: + record = json.loads(json.dumps(V010_RECORD)) + record["fields"]["keep"] = ["leetcode", "2026-10-16T08:12:03.517Z-0000-phone"] + _raw(state_dir, record) + assert not any(stands_down(locker, TOMORROW) for locker in LOCKERS) + assert kept_lockers(TOMORROW) == frozenset(LOCKERS) + + +def test_an_unreadable_pool_stands_nothing_down(state_dir: Path) -> None: + _pool(state_dir).write_text("{not json", encoding="utf-8") + assert not stands_down("alarm", TOMORROW) + assert kept_lockers(TOMORROW) == frozenset() + + +def test_set_kept_lockers_replaces_the_set(state_dir: Path) -> None: + add_sick_day(TOMORROW, keep=["book"], now=TODAY) + entry = set_kept_lockers(TOMORROW, ["alarm", "diet"], now=TODAY) + assert entry.keep == {"alarm", "diet"} + assert kept_lockers(TOMORROW) == {"alarm", "diet"} + assert stands_down("book", TOMORROW) + set_kept_lockers(TOMORROW, [], now=TODAY) + assert all(stands_down(locker, TOMORROW) for locker in LOCKERS) + lines = (state_dir / "audit.jsonl").read_text(encoding="utf-8").splitlines() + events = [json.loads(line) for line in lines] + assert [(e["action"], e["keep"]) for e in events] == [ + ("add", ["book"]), + ("keep", ["alarm", "diet"]), + ("keep", []), + ] + + +def test_a_keep_edit_does_not_restamp_state(state_dir: Path) -> None: + add_sick_day(TOMORROW, now=TODAY) + before = load()[KEY].fields["state"] + set_kept_lockers(TOMORROW, ["leetcode"], now=TODAY) + after = load()[KEY] + assert after.fields["state"] == before + assert after.fields["keep"][1] > before[1] + assert after.fields["actor"][1] == after.fields["keep"][1] + + +def test_a_keep_edit_never_reasserts_sick_over_a_concurrent_clear() -> None: + add_sick_day(TOMORROW, now=TODAY) + added = load() + set_kept_lockers(TOMORROW, ["leetcode"], now=TODAY) + edited = load() + state_tick = added[KEY].fields["state"][1] + # "!" sorts before any uuid, so this clear is concurrent with, and + # older than, the keep edit -- the case a re-stamped state would win. + clear_tick = Hlc(state_tick.wall_time_ms, state_tick.counter + 1, "!") + assert edited[KEY].fields["keep"][1] > clear_tick + cleared = Record( + id=KEY, + fields={ + "state": ("cleared", clear_tick), + "actor": ("phone", clear_tick), + "keep": ([], clear_tick), + }, + ) + merged = merge_logs(edited, {KEY: cleared}) + assert merged[KEY].fields["state"][0] == "cleared" + + +def test_a_keep_edit_beats_a_state_from_a_clock_that_runs_ahead( + state_dir: Path, +) -> None: + future = "2099-01-01T00:00:00.000Z-0000-phone" + record = json.loads(json.dumps(V010_RECORD)) + record["fields"]["state"][1] = future + record["fields"]["actor"][1] = future + _raw(state_dir, record) + set_kept_lockers(TOMORROW, ["workout"], now=TODAY) + assert kept_lockers(TOMORROW) == {"workout"} + assert not stands_down("workout", TOMORROW) + + +def test_a_clear_and_re_add_drops_a_stale_keep_from_another_node() -> None: + add_sick_day(TOMORROW, keep=["leetcode"], now=TODAY) + old_copy = load() + remove_sick_day(TOMORROW, now=TODAY) + add_sick_day(TOMORROW, now=TODAY) + merged = merge_logs(load(), old_copy) + assert merged[KEY].fields["keep"][0] == [] + + +def test_a_0_1_0_re_add_makes_an_older_keep_stale(state_dir: Path) -> None: + add_sick_day(TOMORROW, keep=["leetcode"], now=TODAY) + newer = "2099-01-01T00:00:00.000Z-0000-oldphone" + record = json.loads(json.dumps(V010_RECORD)) + record["fields"]["state"] = ["sick", newer] + merged = merge_logs(load(), {KEY: Record.from_dict(record)}) + write_pool(_pool(state_dir), merged) + assert stands_down("leetcode", TOMORROW) + + +def test_the_keep_edit_refusals() -> None: + with pytest.raises(NotSickError): + set_kept_lockers(TOMORROW, ["book"], now=TODAY) + with pytest.raises(PastDateError, match="changed"): + set_kept_lockers(date(2026, 10, 15), ["book"], now=TODAY) + add_sick_day(TOMORROW, now=TODAY) + with pytest.raises(ValueError, match="unknown locker"): + set_kept_lockers(TOMORROW, ["nope"], now=TODAY) + remove_sick_day(TOMORROW, now=TODAY) + with pytest.raises(NotSickError): + set_kept_lockers(TOMORROW, ["book"], now=TODAY)