screen-locker/scripts/restore_and_bonus.py
Krzysztof kuhy Rudnicki f4850670ee Make the recovery scripts say why they failed, not just print it
The three helper scripts were only ever dry-run, so the no-silent-failures hook
never saw them until they were committed. Six handlers reported the reason to
stdout via _report() and nothing else — invisible to the log, and easy to miss
when the script runs as one step of finish_2026_08_24.sh amid a wall of other
output. These mutate benefit and sick-day state, so a quiet failure there reads
as success.

Log at error/warning alongside the stdout line, saying what was not done:
the bonus was not banked, the revocation was not persisted, the credit gate
cannot tell and so fails closed.

Verified by feeding the gate a corrupt log: it now logs the reason with a
traceback and exits 1, instead of printing one line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJmPLyT8NU4DzDJPV1KEX5
2026-08-24 13:10:53 +02:00

147 lines
5.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""Undo a sick day's shutdown penalty and grant the agreed bonus hours.
Two separate corrections, in this order:
1. **Restore the baseline.** Taking a sick day pulled the shutdown hours down
to 21/21 and stashed the real values in ``sick_day_state.json`` as
``original_mon_wed_hour`` / ``original_thu_sun_hour`` (both 22). Restoring
comes first, because otherwise a "bonus" merely returns you to baseline and
the compensation is silently swallowed.
2. **Bank the bonus.** Written into ``extra_benefits_state.json`` under
``weekly_shutdown_bonus_hours[<ISO week>]`` -- the same key
``_extra_benefits.weekly_shutdown_bonus_hours()`` reads and
``_apply_weekly_shutdown_bonus`` applies every day of the week. This is the
mechanism the locker already uses for streak rewards; nothing bespoke.
Two bonus hours by default: one for each day a completed workout was not
counted (2026-06-12 and 2026-08-24).
Usage:
python3 scripts/restore_and_bonus.py --date 2026-08-24
python3 scripts/restore_and_bonus.py --date 2026-08-24 --bonus-hours 3
"""
from __future__ import annotations
import argparse
from datetime import datetime
import json
import logging
from pathlib import Path
import sys
REPO_ROOT = Path(__file__).resolve().parent.parent
SICK_DAY_STATE_FILE = REPO_ROOT / "screen_locker" / "sick_day_state.json"
EXTRA_BENEFITS_FILE = REPO_ROOT / "screen_locker" / "extra_benefits_state.json"
DEFAULT_BONUS_HOURS = 2
# Failures go to the log (stderr) as well as stdout: this script runs as one
# step of finish_2026_08_24.sh, where a bare stdout line scrolls past in a wall
# of other output, and it mutates benefit state — the exact case where a quiet
# failure looks like success.
_logger = logging.getLogger(__name__)
def _report(message: str) -> None:
"""Write one line to stdout (matches the other scripts in this directory)."""
sys.stdout.write(f"{message}\n")
def _iso_week(date_str: str) -> str:
"""Return ``YYYY-Www`` for ``date_str``, matching ``_current_iso_week``."""
parsed = datetime.strptime(date_str, "%Y-%m-%d").astimezone()
year, week, _ = parsed.isocalendar()
return f"{year}-W{week:02d}"
def _load(path: Path) -> dict:
"""Return ``path``'s JSON object, or an empty dict when absent."""
if not path.is_file():
return {}
try:
return json.loads(path.read_text())
except (OSError, json.JSONDecodeError) as exc:
_logger.warning("Could not read %s: %s — treating as empty", path, exc)
_report(f"WARN: could not read {path}: {exc} — treating as empty")
return {}
def restore_hours(state: dict) -> tuple[int, int] | None:
"""Return the (mon_wed, thu_sun) hours the sick day displaced, if any."""
mon_wed = state.get("original_mon_wed_hour")
thu_sun = state.get("original_thu_sun_hour")
if mon_wed is None or thu_sun is None:
return None
return int(mon_wed), int(thu_sun)
def main() -> int:
"""Restore the pre-sick-day shutdown hours and bank the bonus."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--date", required=True, help="the revoked sick day")
parser.add_argument("--bonus-hours", type=int, default=DEFAULT_BONUS_HOURS)
parser.add_argument("--sick-day-state", default=str(SICK_DAY_STATE_FILE))
parser.add_argument("--benefits-file", default=str(EXTRA_BENEFITS_FILE))
args = parser.parse_args()
sick_state = _load(Path(args.sick_day_state))
restored = restore_hours(sick_state)
if restored is None:
_report(
"No original shutdown hours recorded — the baseline is already "
"whatever the daily reset produces, so nothing to restore."
)
else:
mon_wed, thu_sun = restored
_report(
f"Baseline to restore: Mon-Wed={mon_wed}:00, Thu-Sun={thu_sun}:00 "
"(the daily reset re-applies this on the next run)"
)
benefits = _load(Path(args.benefits_file))
week = _iso_week(args.date)
bonus_map = dict(benefits.get("weekly_shutdown_bonus_hours", {}))
existing = int(bonus_map.get(week, 0))
# Idempotence guard. Banking is `existing + bonus`, so without a durable
# record of which dates were already compensated a re-run silently grants
# the hours a second time. The bonus map alone cannot answer that question:
# a week's total mixes earned streak hours with these corrections, so no
# arithmetic on it can tell "already applied" from "earned that much".
granted = list(benefits.get("shutdown_bonus_granted_for", []))
if args.date in granted:
_report(
f"Bonus for {args.date} was already banked into {week} "
f"(currently {existing}h) — not granting it twice."
)
return 0
granted.append(args.date)
benefits["shutdown_bonus_granted_for"] = granted
bonus_map[week] = existing + args.bonus_hours
benefits["weekly_shutdown_bonus_hours"] = bonus_map
try:
Path(args.benefits_file).write_text(json.dumps(benefits, indent=2) + "\n")
except OSError as exc:
_logger.exception(
"Could not write %s — the bonus was NOT banked",
args.benefits_file,
)
_report(f"FAIL: could not write {args.benefits_file}: {exc}")
return 1
_report(
f"Banked +{args.bonus_hours}h shutdown bonus for {week} "
f"(was {existing}h, now {bonus_map[week]}h) — applied every day of "
"that week on top of the restored baseline."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())