mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:47:38 +01:00
Project.kind (focus default; old rows load unchanged) plus interview fields test_at, invite_url, trips, weekend_answers. Interview budget 30 min weekdays / 120 Sat-Sun, exempt from daily_floor, unlock clamped after the 0.7.0 history ratchet. Milestones are generated (rules, trip_ask, phone, mock, slot), never stored. Append-only evidence store behind the _paths seam. Calendar dropped: slot = slot entry + slot_confirm. Failure paths: - unanswered trip question: fail closed (reads as a trip, phone due) - late no-trip answer cannot erase an overdue phone milestone: closed - phone source missing/stale/crashed: unknown, locks: fail closed - trip_ask answer: evidence row first, then registry; a crash between leaves the question open: fail closed - add_trip/answer_weekend loosening: ValueError, nothing written - owed_lock with unreadable registry or evidence: fail open, logged (focus-guard's unreadable-registry rule); test day never locks - status() milestones with unreadable evidence: empty list, logged - torn or non-utf8 evidence lines: skipped and logged Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWZUTC9Zzdzsx7VW7dm
181 lines
6.2 KiB
Python
181 lines
6.2 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
"""The registry API: active, stands_down, create/end/list."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import replace
|
|
import logging
|
|
import re
|
|
import time
|
|
from typing import TYPE_CHECKING
|
|
|
|
from focus_project import _clock, _interview, _store
|
|
from focus_project._errors import RegistryError
|
|
from focus_project._kind import FOCUS
|
|
from focus_project._model import GATES, Project, normalize_gate
|
|
|
|
if TYPE_CHECKING:
|
|
from datetime import date, datetime
|
|
|
|
from focus_project._trips import Trip
|
|
|
|
_LOG = logging.getLogger(__name__)
|
|
_ID = re.compile(r"[a-z0-9][a-z0-9-]*")
|
|
MAX_WORK_URL = _interview.MAX_URL
|
|
# A project must ask real work: the focus earner pays per minute of ``daily``.
|
|
MIN_DAILY_PER_GATE = 30
|
|
|
|
|
|
def list_projects() -> list[Project]:
|
|
"""Every project in the registry, in file order."""
|
|
return _store.read_all()
|
|
|
|
|
|
def active(day: date) -> Project | None:
|
|
"""The project active on ``day``, or None. Raises on a corrupt registry."""
|
|
hits = [p for p in _store.read_all() if p.covers(day)]
|
|
if len(hits) > 1:
|
|
msg = f"projects overlap on {day}: {[p.id for p in hits]}"
|
|
raise RegistryError(msg)
|
|
return hits[0] if hits else None
|
|
|
|
|
|
def stands_down(gate: str, day: date) -> bool:
|
|
"""True iff the active project replaces ``gate``; never raises.
|
|
|
|
Fail-closed: an unreadable registry means the gate stays ON (logged),
|
|
never that a gate is silently switched off.
|
|
"""
|
|
try:
|
|
project = active(day)
|
|
except (RegistryError, OSError) as exc:
|
|
_LOG.warning(
|
|
"focus_project registry unreadable, gate %s stays on: %s", gate, exc
|
|
)
|
|
return False
|
|
return project is not None and project.replaces_gate(gate, day)
|
|
|
|
|
|
def _validate_work_url(url: str) -> None:
|
|
"""Empty, or a plain http(s) URL with a host: it goes to ``xdg-open``."""
|
|
_interview.check_http_url(url, "work_url")
|
|
|
|
|
|
def daily_floor(replaces: tuple[str, ...]) -> int:
|
|
"""The least ``daily_minutes`` a project replacing these gates may ask."""
|
|
return MIN_DAILY_PER_GATE * max(1, len(replaces))
|
|
|
|
|
|
def _validate(project: Project) -> None:
|
|
_validate_work_url(project.work_url)
|
|
if not _ID.fullmatch(project.id):
|
|
msg = f"id {project.id!r} must be a lowercase slug"
|
|
raise ValueError(msg)
|
|
if not project.name.strip():
|
|
msg = "name must not be blank"
|
|
raise ValueError(msg)
|
|
if project.end < project.start:
|
|
msg = "end is before start"
|
|
raise ValueError(msg)
|
|
if not project.replaces or not set(project.replaces) <= GATES:
|
|
msg = f"replaces must be a non-empty subset of gates {sorted(GATES)}"
|
|
raise ValueError(msg)
|
|
_interview.validate(project)
|
|
floor = daily_floor(project.replaces)
|
|
if project.kind == FOCUS and project.daily_minutes < floor:
|
|
msg = (
|
|
f"daily_minutes must be at least {floor} "
|
|
f"({MIN_DAILY_PER_GATE} per replaced gate)"
|
|
)
|
|
raise ValueError(msg)
|
|
if not 0 <= project.unlock_minutes <= project.daily_minutes:
|
|
msg = "unlock_minutes must be within 0..daily_minutes"
|
|
raise ValueError(msg)
|
|
|
|
|
|
def create_project( # noqa: PLR0913
|
|
project_id: str,
|
|
*,
|
|
name: str,
|
|
start: date,
|
|
end: date,
|
|
replaces: list[str],
|
|
daily_minutes: int,
|
|
unlock_minutes: int,
|
|
subjects: list[str] | None = None,
|
|
site_patterns: list[str] | None = None,
|
|
work_url: str = "",
|
|
kind: str = FOCUS,
|
|
test_at: datetime | None = None,
|
|
invite_url: str = "",
|
|
trips: list[Trip] | None = None,
|
|
weekend_answers: dict[date, bool] | None = None,
|
|
) -> Project:
|
|
"""Add a project; ValueError on bad input, a duplicate id or an overlap.
|
|
|
|
``kind="interview"`` takes ``test_at`` (naive local; its date must be
|
|
``end``), ``invite_url``, ``trips`` and ``weekend_answers``; an interview
|
|
is exempt from :func:`daily_floor` and runs the 30/120 budget.
|
|
"""
|
|
created_ms = time.time_ns() // 1_000_000
|
|
project = Project(
|
|
id=project_id,
|
|
name=name,
|
|
start=start,
|
|
end=end,
|
|
replaces=tuple(dict.fromkeys(normalize_gate(g) for g in replaces)),
|
|
daily_minutes=daily_minutes,
|
|
unlock_minutes=unlock_minutes,
|
|
subjects=tuple(subjects or ()),
|
|
site_patterns=tuple(site_patterns or ()),
|
|
work_url=work_url,
|
|
created_ms=created_ms,
|
|
kind=kind,
|
|
test_at=test_at,
|
|
invite_url=invite_url,
|
|
trips=tuple(
|
|
t if t.entered_ms else replace(t, entered_ms=created_ms)
|
|
for t in trips or ()
|
|
),
|
|
weekend_answers=tuple(sorted((weekend_answers or {}).items())),
|
|
)
|
|
_validate(project)
|
|
with _store.locked():
|
|
existing = _store.read_all()
|
|
for other in existing:
|
|
if other.id == project.id:
|
|
msg = f"project {project.id!r} already exists"
|
|
raise ValueError(msg)
|
|
if project.start <= other.last_day and other.start <= project.last_day:
|
|
msg = f"project overlaps {other.id!r} ({other.start}..{other.last_day})"
|
|
raise ValueError(msg)
|
|
_store.write_all([*existing, project])
|
|
return project
|
|
|
|
|
|
def end_project(project_id: str, day: date) -> Project:
|
|
"""End early: ``day`` becomes the last active day (clamped to ``end``).
|
|
|
|
``day`` must be today or later: ending a past day would retroactively
|
|
erase that day's shortfall (and the lock it causes).
|
|
"""
|
|
with _store.locked():
|
|
existing = _store.read_all()
|
|
for i, project in enumerate(existing):
|
|
if project.id != project_id:
|
|
continue
|
|
if project.ended_on is not None:
|
|
msg = f"project {project_id!r} already ended on {project.ended_on}"
|
|
raise ValueError(msg)
|
|
if day < project.start:
|
|
msg = f"cannot end {project_id!r} before its start {project.start}"
|
|
raise ValueError(msg)
|
|
if day < _clock.today():
|
|
msg = f"cannot end {project_id!r} in the past ({day})"
|
|
raise ValueError(msg)
|
|
ended = replace(project, ended_on=min(day, project.end))
|
|
existing[i] = ended
|
|
_store.write_all(existing)
|
|
return ended
|
|
raise KeyError(project_id)
|