wake-alarm/wake_alarm/_audit.py
Krzysztof kuhy Rudnicki 5d0f06b19e PC: sick days stand the alarm down only when it is not kept
All PC enforcement (fallback ring, RTC self-wake, trigger, workday penalty
write/retract, audit) now asks sickdays.stands_down(LOCKER_ALARM, day) via
_sick.alarm_stands_down instead of is_sick_day, so a sick day that keeps the
alarm behaves exactly as a normal stick day. Bump sickdays pin to v0.2.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
2026-10-16 15:13:23 +02:00

194 lines
6.7 KiB
Python

"""The daily wake audit: one Markdown report per morning, for Claude to read.
``wake-alarm-audit.timer`` runs it at 11:05; by hand::
python -m wake_alarm._audit [YYYY-MM-DD] [--dry-run]
It merges the phone's ``phone_events`` with the PC's named ``events.jsonl``
lines, boots and resumes into one timeline, runs the expected-vs-observed
checks (``_audit_checks``) and writes ``INCIDENTS_DIR/<date>.md`` with every
escape and mismatch first, under ``## Flags``. Read-only everywhere except
that one file (and ``audit_written``); ``--dry-run`` prints without writing.
Outside the repo on purpose: the report holds personal data.
"""
from __future__ import annotations
import argparse
from datetime import date, datetime
import sys
from typing import TYPE_CHECKING
import freedays
from wake_alarm._audit_checks import alarm_from_events, flags, run_checks
from wake_alarm._audit_evidence import Check, Evidence, PcSide, PhoneSide
from wake_alarm._audit_sources import (
boots,
phone_window,
read_pc_log,
resumes,
timeline_window,
)
from wake_alarm._constants import INCIDENTS_DIR
from wake_alarm._enforcement_payload import EnforcementView, enforcement_view
from wake_alarm._enforcement_records import parse_instant
from wake_alarm._events import emit
from wake_alarm._fallback_policy import alarm_instant
from wake_alarm._logging_setup import configure_logging
from wake_alarm._sick import alarm_stands_down
from wake_alarm._sync import get_alarm
from wake_alarm._sync_transport import fetch_firebase_logs
from wake_alarm._workday_penalty import issued_date
if TYPE_CHECKING:
from wake_alarm._enforcement_records import PhoneEvent
_MARK = {True: "[x]", False: "[ ]", None: "[-]"}
def gather(day: date) -> tuple[Evidence, bool]:
"""Collect the evidence for ``day``; second item is Firebase reachability."""
published = fetch_firebase_logs()
view = enforcement_view(published) if published is not None else EnforcementView()
start, end = phone_window(day)
pc, pc_seen = read_pc_log(day)
alarm_at, source = alarm_from_events(pc, day), "PC fallback tick"
if alarm_at is None:
alarm_at, source = alarm_instant(get_alarm(), day), "current alarm, assumed"
phone = [
e
for e in view.phone_events
if (at := parse_instant(e.at)) is not None and start <= at < end
]
evidence = Evidence(
day=day,
stick_day=freedays.is_workday(day),
free_day=freedays.is_free_day(day),
sick_day=alarm_stands_down(day),
alarm_at=alarm_at,
alarm_source=source,
pc_side=PcSide(
events=pc,
seen=pc_seen,
ups=sorted(boots(day) + resumes(day), key=lambda u: u.at),
penalty_issued=issued_date(),
),
phone_side=PhoneSide(
events=sorted(phone, key=lambda e: parse_instant(e.at) or start),
escapes=[e for e in view.escapes if e.date == day.isoformat()],
ring_started=view.ring_started_on(day.isoformat()),
stick_state=view.stick_state,
),
)
return evidence, published is not None
def _shown(ev: Evidence, at: datetime, phone: PhoneEvent | None = None) -> bool:
"""Inside the timeline window, or a late check that judged this day."""
start, end = timeline_window(ev.day)
if start <= at < end:
return True
return phone is not None and phone.data.get("date") == ev.day.isoformat()
def _row(at: datetime, source: str, name: str, data: dict[str, object]) -> str:
"""One ``| time | source | event | details |`` row."""
detail = ", ".join(f"{k}={v}" for k, v in sorted(data.items()))
return f"| {at:%m-%d %H:%M:%S} | {source} | {name} | {detail} |"
def _timeline(ev: Evidence) -> list[str]:
"""The merged rows, oldest first."""
rows = [
(pc.at, _row(pc.at, "PC", pc.event, pc.fields))
for pc in ev.pc
if _shown(ev, pc.at)
]
for phone in ev.phone:
at = parse_instant(phone.at)
if at is not None and _shown(ev, at, phone):
rows.append((at, _row(at, "phone", phone.kind, phone.data)))
rows.extend(
(up.at, _row(up.at, "kernel", up.kind, {}))
for up in ev.ups
if _shown(ev, up.at)
)
return [row for _, row in sorted(rows, key=lambda r: r[0])]
def render(ev: Evidence, checks: list[Check], *, reachable: bool) -> str:
"""The whole report."""
kind = (
"sick day"
if ev.sick_day
else "free day"
if ev.free_day
else "stick day"
if ev.stick_day
else "carrot day"
)
lines = [f"# Wake audit {ev.day} ({ev.day:%a}, {kind})", "", "## Flags", ""]
flagged = flags(ev, checks)
if not reachable:
flagged.append("MISMATCH firebase: unreachable -- phone side not audited")
lines.extend(f"- {line}" for line in flagged or ["none"])
lines += ["", "## Checks", ""]
lines.extend(f"- {_MARK[c.ok]} {c.name}: {c.detail}" for c in checks)
state = ev.stick_state
lines += [
"",
"## Phone state now",
"",
f"- stick_state: {state if state is not None else 'not published'}",
f"- workday_penalty.json issued: {ev.penalty_issued or 'none'}",
"",
"## Timeline",
"",
"| time | source | event | details |",
"|---|---|---|---|",
]
lines.extend(_timeline(ev) or ["| | | (nothing recorded) | |"])
return "\n".join(lines) + "\n"
def write_report(day: date, text: str) -> str:
"""Write the report; returns its path."""
path = INCIDENTS_DIR / f"{day.isoformat()}.md"
INCIDENTS_DIR.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return str(path)
def main(argv: list[str] | None = None) -> int:
"""Entry point; exit 1 when the report could not be written."""
parser = argparse.ArgumentParser(description="Audit one wake-up")
parser.add_argument("day", nargs="?", type=date.fromisoformat, default=None)
parser.add_argument("--dry-run", action="store_true", help="print, write nothing")
args = parser.parse_args(sys.argv[1:] if argv is None else argv)
day = args.day or datetime.now().astimezone().date()
evidence, reachable = gather(day)
checks = run_checks(evidence)
text = render(evidence, checks, reachable=reachable)
sys.stdout.write(text)
if args.dry_run:
return 0
configure_logging(component="audit")
try:
path = write_report(day, text)
except OSError as exc:
sys.stderr.write(f"could not write the report: {exc}\n")
return 1
emit(
"audit_written",
path,
day=day.isoformat(),
path=path,
flags=len(flags(evidence, checks)),
)
return 0
if __name__ == "__main__":
sys.exit(main())