agent-todo-tracker/agent_todos/box.py
Hermes Agent 7a03de305a feat(box): ADHD-friendly time-boxing mode (issue #16)
- TimeBox model with explicit recoverable BoxState state machine
- Store layer: time_boxes schema, CRUD, active-box recovery
- BoxController: start/pause/resume/interrupt/complete/expire with elapsed math
- CLI  group (start/status/pause/resume/interrupt/complete/abandon/recover/list/focus)
- Live ADHD-friendly focus REPL with single-key commands, calm UX, no-shame quit
- Interruptions logged with timestamp + reason for pattern recognition
- Non-interactive helpers + auto-resume on entry
- 65 tests green, strict vertical RED-GREEN TDD
2026-08-22 21:49:35 +00:00

125 lines
4.8 KiB
Python

"""BoxController: drives state transitions and elapsed-time bookkeeping.
Pure logic, no I/O. Takes any object that quacks like a Store (get/update on
time_boxes) so it stays testable and decoupled from SQLite.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional, Protocol
from .timebox import BoxState, TimeBox
class TimeBoxStore(Protocol):
def get_timebox(self, box_id: int) -> Optional[ TimeBox]: ...
def update_timebox(self, box_id: int, **kwargs: Any) -> Optional[TimeBox]: ...
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _now_epoch() -> float:
return datetime.now(timezone.utc).timestamp()
class BoxController:
"""State-machine driver for a single TimeBox.
Every public method validates the transition via TimeBox.can(), applies
elapsed-time accounting, persists via the store, and returns the updated box.
"""
def __init__(self, store: TimeBoxStore) -> None:
self.store = store
def _accrue_and_freeze(self, box: TimeBox) -> None:
"""Fold live running time into accumulated_seconds and clear the clock."""
if box.state == BoxState.running and box.last_started_at is not None:
try:
started = datetime.fromisoformat(box.last_started_at).timestamp()
box.accumulated_seconds += max(0.0, _now_epoch() - started)
except (ValueError, TypeError):
pass
box.last_started_at = None
def _persist(self, box: TimeBox, **fields: Any) -> TimeBox:
if box.id is None:
raise ValueError("box has no id — persist via Store.create_timebox first")
updated = self.store.update_timebox(box.id, **fields)
if updated is None:
raise ValueError(f"box {box.id} disappeared during update")
return updated
def start(self, box: TimeBox) -> TimeBox:
if box.is_terminal():
raise ValueError(f"Cannot start terminal box in state {box.state.value}")
if box.state != BoxState.open:
raise ValueError(f"Cannot start box in state {box.state.value}; use resume()")
box.last_started_at = _now_iso()
return self._persist(box, state=BoxState.running, last_started_at=box.last_started_at)
def pause(self, box: TimeBox) -> TimeBox:
if box.is_terminal():
raise ValueError(f"Cannot pause terminal box in state {box.state.value}")
if box.state != BoxState.running:
raise ValueError(f"Cannot pause box in state {box.state.value}")
self._accrue_and_freeze(box)
return self._persist(box, state=BoxState.paused, accumulated_seconds=box.accumulated_seconds)
def resume(self, box: TimeBox) -> TimeBox:
if box.is_terminal():
raise ValueError(f"Cannot resume terminal box in state {box.state.value}")
if box.state not in (BoxState.paused, BoxState.interrupted):
raise ValueError(f"Cannot resume box in state {box.state.value}")
box.last_started_at = _now_iso()
return self._persist(box, state=BoxState.running, last_started_at=box.last_started_at)
def interrupt(self, box: TimeBox, reason: str) -> TimeBox:
if box.is_terminal():
raise ValueError(f"Cannot interrupt terminal box in state {box.state.value}")
self._accrue_and_freeze(box)
interruptions = list(box.interruptions)
interruptions.append({"at": _now_iso(), "reason": reason})
return self._persist(
box,
state=BoxState.interrupted,
accumulated_seconds=box.accumulated_seconds,
interruptions=interruptions,
reason=reason,
)
def complete(self, box: TimeBox) -> TimeBox:
if box.is_terminal():
raise ValueError(f"Cannot complete terminal box in state {box.state.value}")
self._accrue_and_freeze(box)
return self._persist(
box,
state=BoxState.completed,
accumulated_seconds=box.accumulated_seconds,
completed_at=_now_iso(),
)
def abandon(self, box: TimeBox, reason: str = "") -> TimeBox:
if box.is_terminal():
raise ValueError(f"Cannot abandon terminal box in state {box.state.value}")
self._accrue_and_freeze(box)
return self._persist(
box,
state=BoxState.abandoned,
accumulated_seconds=box.accumulated_seconds,
reason=reason,
)
def expire(self, box: TimeBox) -> TimeBox:
if box.state != BoxState.running:
raise ValueError(f"Cannot expire box in state {box.state.value}")
self._accrue_and_freeze(box)
return self._persist(
box,
state=BoxState.expired,
accumulated_seconds=box.accumulated_seconds,
completed_at=_now_iso(),
)