mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:27:15 +01:00
Some checks are pending
code-runner pre-commit / pre-commit (push) Waiting to run
code-runner tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
leetcode-coach pre-commit / pre-commit (push) Waiting to run
leetcode-coach tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sql-drills pre-commit / pre-commit (push) Waiting to run
sql-drills tests / test (push) Waiting to run
Minutes past a day's target bank (max 60) and pay on the next project day; status shows real minutes (121/120) plus carried_in/carry_out; carry ledger rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
223 lines
6.9 KiB
Python
223 lines
6.9 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
"""The signed credit ledger the ``focus`` earner reads.
|
|
|
|
Row contract (earned_time's "tutor row contract"): ``detail.minutes`` is the
|
|
positive increment the row pays, ``entry_id`` is
|
|
``<project>-<YYYYMMDD>-m<n>`` with ``n`` the day's cumulative credited
|
|
minutes AFTER the row (so ids never overlap and a retry rewrites the same
|
|
id), ``day`` is the local date, ``detail.ended_at`` is when the last paid
|
|
minute ended and never passes the day's end. The only writer is the
|
|
harvester; every write is read back and verified.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import fcntl
|
|
import json
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
import tempfile
|
|
import time
|
|
from typing import TYPE_CHECKING, Any, Final
|
|
|
|
import earned_time
|
|
|
|
from focus_project._paths import data_dir
|
|
from focus_project.sources import day_bounds
|
|
|
|
if TYPE_CHECKING:
|
|
from datetime import date
|
|
|
|
from focus_project._model import Project
|
|
|
|
CREDIT: Final = "credit"
|
|
KEY_ENV: Final = "FOCUS_PROJECT_KEY"
|
|
_REAL_KEY: Final = Path("/etc/workout-locker/hmac.key")
|
|
_LOG = logging.getLogger(__name__)
|
|
|
|
|
|
class LedgerError(Exception):
|
|
"""The ledger exists but is not a ledger: never silently emptied."""
|
|
|
|
|
|
def key_path() -> Path:
|
|
"""The signing key: ``$FOCUS_PROJECT_KEY`` or the shared root-owned key."""
|
|
override = os.environ.get(KEY_ENV)
|
|
return Path(override) if override else _REAL_KEY
|
|
|
|
|
|
def ledger_path() -> Path:
|
|
"""The ledger file inside the data dir."""
|
|
return data_dir() / "ledger.json"
|
|
|
|
|
|
def read_key() -> bytes | None:
|
|
"""The signing key, or None if unreadable or empty (logged)."""
|
|
try:
|
|
return key_path().read_bytes().strip() or None
|
|
except OSError as exc:
|
|
_LOG.warning("signing key %s unreadable: %s", key_path(), exc)
|
|
return None
|
|
|
|
|
|
def read_rows(path: Path) -> list[Any]:
|
|
"""The raw ``entries`` array; a missing file is empty.
|
|
|
|
Raises:
|
|
LedgerError: unreadable, not JSON, or no entries array.
|
|
"""
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
except FileNotFoundError:
|
|
return []
|
|
except (OSError, ValueError) as exc:
|
|
msg = f"{path} is unreadable: {exc}"
|
|
raise LedgerError(msg) from exc
|
|
rows = raw.get("entries") if isinstance(raw, dict) else None
|
|
if not isinstance(rows, list):
|
|
msg = f"{path} has no entries array"
|
|
raise LedgerError(msg)
|
|
return rows
|
|
|
|
|
|
def _write_rows(path: Path, rows: list[Any]) -> None:
|
|
"""Atomically replace the ledger: readers see the old file or the new one."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with tempfile.NamedTemporaryFile(
|
|
"w", encoding="utf-8", dir=path.parent, suffix=".tmp", delete=False
|
|
) as handle:
|
|
json.dump({"version": 1, "entries": rows}, handle, indent=1)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
Path(handle.name).replace(path)
|
|
|
|
|
|
def paid_minutes(
|
|
rows: list[Any],
|
|
key: bytes,
|
|
project_id: str,
|
|
day: date,
|
|
*,
|
|
carry: bool | None = None,
|
|
) -> int:
|
|
"""Minutes already paid for ``project_id`` on ``day`` by verified rows.
|
|
|
|
``carry`` picks one class (True: banked-overtime rows, False: own-work
|
|
rows); None sums both. A repeated ``entry_id`` pays once, at its smallest
|
|
count.
|
|
"""
|
|
by_id: dict[str, int] = {}
|
|
for row in rows:
|
|
if not (isinstance(row, dict) and row.get("kind") == CREDIT):
|
|
continue
|
|
detail = row.get("detail")
|
|
if not isinstance(detail, dict):
|
|
continue
|
|
minutes = detail.get("minutes")
|
|
if (
|
|
row.get("day") != day.isoformat()
|
|
or detail.get("project") != project_id
|
|
or not isinstance(minutes, int)
|
|
or isinstance(minutes, bool)
|
|
or minutes < 1
|
|
or (carry is not None and bool(detail.get("carry")) != carry)
|
|
or not earned_time.verified(row, key)
|
|
):
|
|
continue
|
|
eid = str(row.get("entry_id"))
|
|
by_id[eid] = min(by_id.get(eid, minutes), minutes)
|
|
return sum(by_id.values())
|
|
|
|
|
|
def build_row( # noqa: PLR0913
|
|
project_id: str,
|
|
day: date,
|
|
total: int,
|
|
minutes: int,
|
|
key: bytes,
|
|
*,
|
|
now: float,
|
|
carry: bool = False,
|
|
first_carry: bool = True,
|
|
) -> dict[str, Any]:
|
|
"""The signed row paying ``minutes`` that bring the day's total to ``total``.
|
|
|
|
A ``carry`` row pays banked overtime: ``<project>-<YYYYMMDD>-carry``, or
|
|
``-carry-m<total>`` when it tops up an earlier carry row (late data for
|
|
the day before grew the bank).
|
|
"""
|
|
ended = min(now, day_bounds(day)[1] - 1)
|
|
stem = f"{project_id}-{day:%Y%m%d}"
|
|
if not carry:
|
|
entry_id = f"{stem}-m{total}"
|
|
else:
|
|
entry_id = f"{stem}-carry" if first_carry else f"{stem}-carry-m{total}"
|
|
row: dict[str, Any] = {
|
|
"kind": CREDIT,
|
|
"entry_id": entry_id,
|
|
"day": day.isoformat(),
|
|
"created_at": float(now),
|
|
"detail": {
|
|
"project": project_id,
|
|
"minutes": int(minutes),
|
|
"total": int(total),
|
|
"ended_at": float(ended),
|
|
**({"carry": True} if carry else {}),
|
|
},
|
|
}
|
|
row["hmac"] = str(earned_time.entry_signature(row, key))
|
|
return row
|
|
|
|
|
|
def credit_to(
|
|
project: Project,
|
|
day: date,
|
|
total: int,
|
|
*,
|
|
now: float | None = None,
|
|
carry: bool = False,
|
|
) -> int:
|
|
"""Pay the day up to ``total`` cumulative minutes; return minutes added.
|
|
|
|
``carry`` pays the banked-overtime class and counts only its own rows;
|
|
the default pays (and counts) own work.
|
|
|
|
Idempotent: nothing is written when the ledger already pays ``total`` or
|
|
more. Raises LedgerError (unreadable ledger, unreadable key, unverifiable
|
|
write) so the harvester can fail loudly instead of crediting nothing quietly.
|
|
"""
|
|
key = read_key()
|
|
if key is None:
|
|
msg = f"signing key {key_path()} unreadable"
|
|
raise LedgerError(msg)
|
|
path = ledger_path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with path.with_suffix(".lock").open("w") as lock:
|
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
rows = read_rows(path)
|
|
paid = paid_minutes(rows, key, project.id, day, carry=carry)
|
|
if total <= paid:
|
|
return 0
|
|
row = build_row(
|
|
project.id,
|
|
day,
|
|
total,
|
|
total - paid,
|
|
key,
|
|
now=now or time.time(),
|
|
carry=carry,
|
|
first_carry=paid == 0,
|
|
)
|
|
if any(
|
|
isinstance(r, dict) and r.get("entry_id") == row["entry_id"] for r in rows
|
|
):
|
|
msg = f"entry_id {row['entry_id']} already present but unverified"
|
|
raise LedgerError(msg)
|
|
_write_rows(path, [*rows, row])
|
|
stored = read_rows(path)
|
|
if paid_minutes(stored, key, project.id, day, carry=carry) != total:
|
|
msg = f"row {row['entry_id']} not found verified on read-back"
|
|
raise LedgerError(msg)
|
|
return total - paid
|