- 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
280 lines
10 KiB
Python
280 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from .models import Priority, Status, Todo
|
|
from .timebox import BoxState, TimeBox
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS todos (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
repo TEXT DEFAULT '',
|
|
status TEXT NOT NULL DEFAULT 'open',
|
|
priority TEXT NOT NULL DEFAULT 'medium',
|
|
assignee TEXT DEFAULT '',
|
|
tags TEXT DEFAULT '',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
resolved_at TEXT DEFAULT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(status);
|
|
CREATE INDEX IF NOT EXISTS idx_todos_repo ON todos(repo);
|
|
|
|
CREATE TABLE IF NOT EXISTS time_boxes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
todo_id INTEGER REFERENCES todos(id),
|
|
duration_seconds INTEGER NOT NULL,
|
|
state TEXT NOT NULL DEFAULT 'open',
|
|
accumulated_seconds REAL NOT NULL DEFAULT 0.0,
|
|
last_started_at TEXT,
|
|
interruptions TEXT NOT NULL DEFAULT '[]',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
completed_at TEXT,
|
|
reason TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_timebox_state ON time_boxes(state);
|
|
"""
|
|
|
|
|
|
class Store:
|
|
def __init__(self, db_path: str = ".agent_todos.db") -> None:
|
|
self.db_path = db_path
|
|
self._ensure()
|
|
|
|
def _ensure(self) -> None:
|
|
first = not Path(self.db_path).exists()
|
|
with sqlite3.connect(self.db_path) as con:
|
|
con.executescript(SCHEMA)
|
|
con.commit()
|
|
if first:
|
|
self.seed()
|
|
|
|
def seed(self) -> None:
|
|
now = datetime.utcnow().isoformat() + "Z"
|
|
self.create(Todo(
|
|
id=None,
|
|
title="Scaffold agent-todo-tracker CLI",
|
|
repo="stackchain/agent-todo-tracker",
|
|
status=Status.resolved,
|
|
priority=Priority.high,
|
|
tags=["hackathon", "p2"],
|
|
created_at=now,
|
|
updated_at=now,
|
|
resolved_at=now,
|
|
))
|
|
self.create(Todo(
|
|
id=None,
|
|
title="Add Gitea issue sync for TODOs",
|
|
repo="stackchain/stackchain-hackathon",
|
|
status=Status.open,
|
|
priority=Priority.medium,
|
|
tags=["hackathon", "p1"],
|
|
created_at=now,
|
|
updated_at=now,
|
|
))
|
|
|
|
def create(self, todo: Todo) -> Todo:
|
|
now = datetime.utcnow().isoformat() + "Z"
|
|
todo.created_at = now
|
|
todo.updated_at = now
|
|
with sqlite3.connect(self.db_path) as con:
|
|
cur = con.execute(
|
|
"INSERT INTO todos (title, repo, status, priority, assignee, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(
|
|
todo.title,
|
|
todo.repo,
|
|
todo.status.value,
|
|
todo.priority.value,
|
|
todo.assignee,
|
|
",".join(todo.tags),
|
|
todo.created_at,
|
|
todo.updated_at,
|
|
),
|
|
)
|
|
todo.id = cur.lastrowid
|
|
con.commit()
|
|
return todo
|
|
|
|
def list(self, status: Optional[Status] = None, repo: Optional[str] = None) -> list[Todo]:
|
|
query = "SELECT * FROM todos WHERE 1=1"
|
|
params: list = []
|
|
if status:
|
|
query += " AND status = ?"
|
|
params.append(status.value)
|
|
if repo:
|
|
query += " AND repo = ?"
|
|
params.append(repo)
|
|
query += " ORDER BY created_at DESC"
|
|
with sqlite3.connect(self.db_path) as con:
|
|
con.row_factory = sqlite3.Row
|
|
rows = con.execute(query, params).fetchall()
|
|
return [self._row_to_todo(r) for r in rows]
|
|
|
|
def update(self, todo_id: int, **kwargs) -> Optional[Todo]:
|
|
todo = self.get(todo_id)
|
|
if not todo:
|
|
return None
|
|
allowed = {"title", "repo", "status", "priority", "assignee", "tags", "resolved_at"}
|
|
fields = {k: v for k, v in kwargs.items() if k in allowed}
|
|
if not fields:
|
|
return todo
|
|
now = datetime.utcnow().isoformat() + "Z"
|
|
sets = ["updated_at = ?"]
|
|
params = [now]
|
|
for k, v in fields.items():
|
|
if k == "tags" and isinstance(v, list):
|
|
v = ",".join(v)
|
|
if k == "status":
|
|
v = v.value if isinstance(v, Status) else v
|
|
if k == "priority":
|
|
v = v.value if isinstance(v, Priority) else v
|
|
sets.append(f"{k} = ?")
|
|
params.append(v)
|
|
if fields.get("status") == Status.resolved.value:
|
|
sets.append("resolved_at = ?")
|
|
params.append(now)
|
|
params.append(todo_id)
|
|
with sqlite3.connect(self.db_path) as con:
|
|
con.execute(f"UPDATE todos SET {', '.join(sets)} WHERE id = ?", params)
|
|
con.commit()
|
|
return self.get(todo_id)
|
|
|
|
def get(self, todo_id: int) -> Optional[Todo]:
|
|
with sqlite3.connect(self.db_path) as con:
|
|
con.row_factory = sqlite3.Row
|
|
row = con.execute("SELECT * FROM todos WHERE id = ?", (todo_id,)).fetchone()
|
|
return self._row_to_todo(row) if row else None
|
|
|
|
def _row_to_todo(self, row: sqlite3.Row) -> Todo:
|
|
return Todo(
|
|
id=row["id"],
|
|
title=row["title"],
|
|
repo=row["repo"],
|
|
status=Status(row["status"]),
|
|
priority=Priority(row["priority"]),
|
|
assignee=row["assignee"],
|
|
tags=[t for t in row["tags"].split(",") if t],
|
|
created_at=row["created_at"],
|
|
updated_at=row["updated_at"],
|
|
resolved_at=row["resolved_at"],
|
|
)
|
|
|
|
# ----- time_boxes -----------------------------------------------------
|
|
|
|
def create_timebox(self, box: TimeBox) -> TimeBox:
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
box.created_at = now
|
|
box.updated_at = now
|
|
with sqlite3.connect(self.db_path) as con:
|
|
cur = con.execute(
|
|
"INSERT INTO time_boxes "
|
|
"(todo_id, duration_seconds, state, accumulated_seconds, "
|
|
"last_started_at, interruptions, created_at, updated_at, "
|
|
"completed_at, reason) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(
|
|
box.todo_id,
|
|
box.duration_seconds,
|
|
box.state.value,
|
|
box.accumulated_seconds,
|
|
box.last_started_at,
|
|
json.dumps(box.interruptions),
|
|
box.created_at,
|
|
box.updated_at,
|
|
box.completed_at,
|
|
box.reason,
|
|
),
|
|
)
|
|
box.id = cur.lastrowid
|
|
con.commit()
|
|
return box
|
|
|
|
def get_timebox(self, box_id: int) -> Optional[TimeBox]:
|
|
with sqlite3.connect(self.db_path) as con:
|
|
con.row_factory = sqlite3.Row
|
|
row = con.execute(
|
|
"SELECT * FROM time_boxes WHERE id = ?", (box_id,)
|
|
).fetchone()
|
|
return self._row_to_timebox(row) if row else None
|
|
|
|
def list_timeboxes(self, active_only: bool = False) -> list[TimeBox]:
|
|
query = "SELECT * FROM time_boxes"
|
|
if active_only:
|
|
states = ", ".join(f"'{s.value}'" for s in BoxState if s not in {
|
|
BoxState.completed, BoxState.abandoned, BoxState.expired
|
|
})
|
|
query += f" WHERE state IN ({states})"
|
|
query += " ORDER BY updated_at DESC"
|
|
with sqlite3.connect(self.db_path) as con:
|
|
con.row_factory = sqlite3.Row
|
|
rows = con.execute(query).fetchall()
|
|
return [self._row_to_timebox(r) for r in rows]
|
|
|
|
def update_timebox(self, box_id: int, **kwargs) -> Optional[TimeBox]:
|
|
box = self.get_timebox(box_id)
|
|
if not box:
|
|
return None
|
|
allowed = {
|
|
"todo_id", "duration_seconds", "state", "accumulated_seconds",
|
|
"last_started_at", "interruptions", "completed_at", "reason",
|
|
}
|
|
fields = {k: v for k, v in kwargs.items() if k in allowed}
|
|
if not fields:
|
|
return box
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
sets = ["updated_at = ?"]
|
|
params: list = [now]
|
|
for k, v in fields.items():
|
|
if k == "interruptions" and isinstance(v, list):
|
|
v = json.dumps(v)
|
|
if k == "state":
|
|
v = v.value if isinstance(v, BoxState) else v
|
|
sets.append(f"{k} = ?")
|
|
params.append(v)
|
|
params.append(box_id)
|
|
with sqlite3.connect(self.db_path) as con:
|
|
con.execute(
|
|
f"UPDATE time_boxes SET {', '.join(sets)} WHERE id = ?", params
|
|
)
|
|
con.commit()
|
|
return self.get_timebox(box_id)
|
|
|
|
def recover_timebox(self) -> Optional[TimeBox]:
|
|
"""Return the most-recent active box, or None if none exists.
|
|
|
|
Use this after an interruption (crash, reboot, agent restart) so the
|
|
user can resume exactly where they left off.
|
|
"""
|
|
states = ", ".join(f"'{s.value}'" for s in BoxState if s not in {
|
|
BoxState.completed, BoxState.abandoned, BoxState.expired
|
|
})
|
|
query = (
|
|
f"SELECT * FROM time_boxes WHERE state IN ({states}) "
|
|
"ORDER BY updated_at DESC LIMIT 1"
|
|
)
|
|
with sqlite3.connect(self.db_path) as con:
|
|
con.row_factory = sqlite3.Row
|
|
row = con.execute(query).fetchone()
|
|
return self._row_to_timebox(row) if row else None
|
|
|
|
def _row_to_timebox(self, row: sqlite3.Row) -> TimeBox:
|
|
return TimeBox(
|
|
id=row["id"],
|
|
todo_id=row["todo_id"],
|
|
duration_seconds=row["duration_seconds"],
|
|
state=BoxState(row["state"]),
|
|
accumulated_seconds=row["accumulated_seconds"] or 0.0,
|
|
last_started_at=row["last_started_at"],
|
|
interruptions=json.loads(row["interruptions"] or "[]"),
|
|
created_at=row["created_at"],
|
|
updated_at=row["updated_at"],
|
|
completed_at=row["completed_at"],
|
|
reason=row["reason"],
|
|
)
|