utils/focus_project/focus_project/_api.py
Krzysztof kuhy Rudnicki 4c1ffd900b focus_project: interview milestones, evidence
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
2026-11-10 15:39:30 +01:00

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)