feat(coord): add todos system with per-user/machine/project scoping

New coord_todos table and API endpoints (GET/POST/PUT/DELETE /api/coord/todos)
supporting manual and auto-created items, sub-tasks via parent_id, and inclusive
for_user/for_machine filters (OR-null) for sync/save display. sync.sh Phase 7
now shows pending todos grouped by project after every sync. CLAUDE.md documents
auto-creation behavior for unresolved follow-up. Web/email pricing doc updated:
block time rate clarified, INKY reference removed, dates updated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 07:53:22 -07:00
parent 7a5c12d2af
commit 4be89035cc
10 changed files with 552 additions and 5 deletions

View File

@@ -49,6 +49,7 @@ from api.routers import (
coord_components,
coord_messages,
coord_status,
coord_todos,
)
# Import middleware
@@ -162,6 +163,7 @@ app.include_router(coord_locks.router, prefix="/api/coord/locks", tags=["Coordin
app.include_router(coord_components.router, prefix="/api/coord/components", tags=["Coordination"])
app.include_router(coord_messages.router, prefix="/api/coord/messages", tags=["Coordination"])
app.include_router(coord_status.router, prefix="/api/coord/status", tags=["Coordination"])
app.include_router(coord_todos.router, prefix="/api/coord/todos", tags=["Coordination"])
if __name__ == "__main__":

View File

@@ -10,6 +10,7 @@ from api.models.coord_work_item import CoordWorkItem
from api.models.coord_session_lock import CoordSessionLock
from api.models.coord_component_state import CoordComponentState
from api.models.coord_message import CoordMessage
from api.models.coord_todo import CoordTodo
from api.models.backup_log import BackupLog
from api.models.base import Base, TimestampMixin, UUIDMixin
from api.models.billable_time import BillableTime
@@ -57,6 +58,7 @@ __all__ = [
"CoordSessionLock",
"CoordComponentState",
"CoordMessage",
"CoordTodo",
"BackupLog",
"Base",
"BillableTime",

96
api/models/coord_todo.py Normal file
View File

@@ -0,0 +1,96 @@
"""Coordination personal/project to-do item model."""
from datetime import datetime
from typing import Optional
from sqlalchemy import CHAR, Boolean, CheckConstraint, DateTime, ForeignKey, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base, TimestampMixin, UUIDMixin
class CoordTodo(Base, UUIDMixin, TimestampMixin):
"""A personal or project-scoped to-do item, optionally nested under a parent."""
__tablename__ = "coord_todos"
text: Mapped[str] = mapped_column(
Text,
nullable=False,
doc="The to-do item text"
)
project_key: Mapped[Optional[str]] = mapped_column(
String(100),
doc="Project scope; NULL means personal"
)
parent_id: Mapped[Optional[str]] = mapped_column(
CHAR(36),
ForeignKey("coord_todos.id", ondelete="CASCADE"),
doc="Parent to-do ID for sub-tasks; NULL means top-level"
)
assigned_to_user: Mapped[Optional[str]] = mapped_column(
String(50),
doc="Target user, e.g. 'mike' or 'howard'; NULL = any user"
)
assigned_to_machine: Mapped[Optional[str]] = mapped_column(
String(100),
doc="Target hostname; NULL = any machine"
)
auto_created: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
doc="True when Claude auto-generated this item"
)
source_context: Mapped[Optional[str]] = mapped_column(
Text,
doc="Why Claude auto-created this item"
)
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="pending",
doc="Status: pending / done / cancelled"
)
completed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
doc="When the item was marked done"
)
completed_by: Mapped[Optional[str]] = mapped_column(
String(100),
doc="Session or user that completed the item"
)
created_by_user: Mapped[str] = mapped_column(
String(50),
nullable=False,
doc="User who created the item"
)
created_by_machine: Mapped[str] = mapped_column(
String(100),
nullable=False,
doc="Hostname where the item was created"
)
__table_args__ = (
CheckConstraint(
"status IN ('pending', 'done', 'cancelled')",
name="ck_coord_todos_status",
),
Index("idx_coord_todos_project_status", "project_key", "status"),
Index("idx_coord_todos_assigned", "assigned_to_user", "assigned_to_machine", "status"),
Index("idx_coord_todos_parent", "parent_id"),
)
def __repr__(self) -> str:
return f"<CoordTodo(status='{self.status}', text='{self.text[:40]}')>"

View File

@@ -0,0 +1,81 @@
"""Coordination to-do items router."""
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.orm import Session
from api.database import get_db
from api.schemas.coord_todo import CoordTodoCreate, CoordTodoResponse, CoordTodoUpdate
from api.services import coord_todo_service
router = APIRouter()
@router.get("", response_model=list[CoordTodoResponse], status_code=status.HTTP_200_OK)
def list_todos(
project_key: str | None = Query(default=None),
assigned_to_user: str | None = Query(default=None),
assigned_to_machine: str | None = Query(default=None),
for_user: str | None = Query(default=None, description="Return items for this user OR unassigned"),
for_machine: str | None = Query(default=None, description="Return items for this machine OR any machine"),
status_filter: str = Query(default="pending"),
include_subtasks: bool = Query(default=True),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=1000),
db: Session = Depends(get_db),
):
"""List to-do items with optional filters. Pass status_filter=all to include every status."""
todos = coord_todo_service.get_todos(
db,
project_key=project_key,
assigned_to_user=assigned_to_user,
assigned_to_machine=assigned_to_machine,
for_user=for_user,
for_machine=for_machine,
status_filter=status_filter,
include_subtasks=include_subtasks,
skip=skip,
limit=limit,
)
return [CoordTodoResponse.model_validate(t) for t in todos]
@router.post("", response_model=CoordTodoResponse, status_code=status.HTTP_201_CREATED)
def create_todo(
data: CoordTodoCreate,
db: Session = Depends(get_db),
):
"""Create a new to-do item."""
todo = coord_todo_service.create_todo(db, data)
return CoordTodoResponse.model_validate(todo)
@router.get("/{todo_id}", response_model=CoordTodoResponse, status_code=status.HTTP_200_OK)
def get_todo(
todo_id: UUID,
db: Session = Depends(get_db),
):
"""Get a single to-do item including its sub-tasks."""
todo = coord_todo_service.get_todo_by_id(db, todo_id)
return CoordTodoResponse.model_validate(todo)
@router.put("/{todo_id}", response_model=CoordTodoResponse, status_code=status.HTTP_200_OK)
def update_todo(
todo_id: UUID,
data: CoordTodoUpdate,
db: Session = Depends(get_db),
):
"""Update a to-do item. Setting status to 'done' records completed_at automatically."""
todo = coord_todo_service.update_todo(db, todo_id, data)
return CoordTodoResponse.model_validate(todo)
@router.delete("/{todo_id}", response_model=dict, status_code=status.HTTP_200_OK)
def delete_todo(
todo_id: UUID,
db: Session = Depends(get_db),
):
"""Delete a to-do item and all its sub-tasks."""
return coord_todo_service.delete_todo(db, todo_id)

57
api/schemas/coord_todo.py Normal file
View File

@@ -0,0 +1,57 @@
"""Pydantic schemas for CoordTodo."""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
class CoordTodoCreate(BaseModel):
"""Input schema for creating a to-do item."""
text: str = Field(..., description="To-do item text")
project_key: Optional[str] = Field(None, description="Project scope; NULL = personal", max_length=100)
parent_id: Optional[UUID] = Field(None, description="Parent to-do ID for sub-tasks")
assigned_to_user: Optional[str] = Field(None, description="Target user, e.g. 'mike'", max_length=50)
assigned_to_machine: Optional[str] = Field(None, description="Target hostname", max_length=100)
auto_created: bool = Field(False, description="True when Claude auto-generated this item")
source_context: Optional[str] = Field(None, description="Why Claude auto-created this item")
created_by_user: str = Field(..., description="User creating the item", max_length=50)
created_by_machine: str = Field(..., description="Hostname where the item is created", max_length=100)
class CoordTodoUpdate(BaseModel):
"""Input schema for updating a to-do item."""
text: Optional[str] = None
status: Optional[str] = Field(None, description="pending / done / cancelled")
assigned_to_user: Optional[str] = Field(None, max_length=50)
assigned_to_machine: Optional[str] = Field(None, max_length=100)
project_key: Optional[str] = Field(None, max_length=100)
completed_by: Optional[str] = Field(None, description="Session or user completing the item", max_length=100)
class CoordTodoResponse(BaseModel):
"""Output schema for a to-do item."""
id: UUID
text: str
project_key: Optional[str]
parent_id: Optional[UUID]
assigned_to_user: Optional[str]
assigned_to_machine: Optional[str]
auto_created: bool
source_context: Optional[str]
status: str
completed_at: Optional[datetime]
completed_by: Optional[str]
created_by_user: str
created_by_machine: str
created_at: datetime
updated_at: datetime
subtasks: list[CoordTodoResponse] = Field(default_factory=list)
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,170 @@
"""Service layer for CoordTodo."""
from datetime import datetime, timezone
from typing import Optional
from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from api.models.coord_todo import CoordTodo
from api.schemas.coord_todo import CoordTodoCreate, CoordTodoUpdate
_VALID_STATUSES = {"pending", "done", "cancelled"}
def get_todos(
db: Session,
project_key: Optional[str] = None,
assigned_to_user: Optional[str] = None,
assigned_to_machine: Optional[str] = None,
for_user: Optional[str] = None,
for_machine: Optional[str] = None,
status_filter: str = "pending",
include_subtasks: bool = True,
skip: int = 0,
limit: int = 100,
) -> list[CoordTodo]:
"""Return a flat list of to-do items with optional filters.
assigned_to_user/machine: exact match (admin queries).
for_user/for_machine: inclusive match — items for this user/machine OR unassigned.
Used by sync/save to surface relevant pending work.
When include_subtasks is False, only top-level items (parent_id IS NULL) are returned.
"""
from sqlalchemy import or_
q = db.query(CoordTodo)
if not include_subtasks:
q = q.filter(CoordTodo.parent_id.is_(None))
if project_key is not None:
q = q.filter(CoordTodo.project_key == project_key)
if assigned_to_user is not None:
q = q.filter(CoordTodo.assigned_to_user == assigned_to_user)
if assigned_to_machine is not None:
q = q.filter(CoordTodo.assigned_to_machine == assigned_to_machine)
if for_user is not None:
q = q.filter(or_(CoordTodo.assigned_to_user == for_user, CoordTodo.assigned_to_user.is_(None)))
if for_machine is not None:
q = q.filter(or_(CoordTodo.assigned_to_machine == for_machine, CoordTodo.assigned_to_machine.is_(None)))
if status_filter != "all":
q = q.filter(CoordTodo.status == status_filter)
return q.order_by(CoordTodo.created_at.asc()).offset(skip).limit(limit).all()
def get_todo_by_id(db: Session, todo_id: UUID) -> CoordTodo:
"""Return a single to-do item with its sub-tasks loaded, or raise 404."""
todo = db.query(CoordTodo).filter(CoordTodo.id == str(todo_id)).first()
if not todo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Todo {todo_id} not found"
)
todo.subtasks = (
db.query(CoordTodo)
.filter(CoordTodo.parent_id == str(todo_id))
.order_by(CoordTodo.created_at.asc())
.all()
)
return todo
def create_todo(db: Session, todo_in: CoordTodoCreate) -> CoordTodo:
"""Persist a new to-do item."""
if todo_in.parent_id is not None:
parent = db.query(CoordTodo).filter(CoordTodo.id == str(todo_in.parent_id)).first()
if not parent:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Parent todo {todo_in.parent_id} not found"
)
data = todo_in.model_dump()
if data.get("parent_id") is not None:
data["parent_id"] = str(data["parent_id"])
try:
todo = CoordTodo(**data)
db.add(todo)
db.commit()
db.refresh(todo)
todo.subtasks = []
return todo
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create todo: {e}"
)
def update_todo(db: Session, todo_id: UUID, todo_in: CoordTodoUpdate) -> CoordTodo:
"""Apply a partial update to a to-do item.
When status changes to 'done', completed_at is set to the current UTC time.
When status changes away from 'done', completed_at is cleared.
"""
todo = get_todo_by_id(db, todo_id)
patch = todo_in.model_dump(exclude_unset=True)
if "status" in patch and patch["status"] not in _VALID_STATUSES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid status '{patch['status']}'; must be one of {sorted(_VALID_STATUSES)}"
)
for field, value in patch.items():
setattr(todo, field, value)
if "status" in patch:
if patch["status"] == "done" and todo.completed_at is None:
todo.completed_at = datetime.now(timezone.utc).replace(tzinfo=None)
elif patch["status"] != "done":
todo.completed_at = None
try:
db.commit()
db.refresh(todo)
todo.subtasks = (
db.query(CoordTodo)
.filter(CoordTodo.parent_id == str(todo_id))
.order_by(CoordTodo.created_at.asc())
.all()
)
return todo
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update todo: {e}"
)
def delete_todo(db: Session, todo_id: UUID) -> dict:
"""Delete a to-do item and all its sub-tasks.
Sub-tasks are deleted first to satisfy FK constraints on databases that
don't enforce CASCADE at the application level.
"""
todo = get_todo_by_id(db, todo_id)
try:
db.query(CoordTodo).filter(CoordTodo.parent_id == str(todo_id)).delete(synchronize_session=False)
db.delete(todo)
db.commit()
return {"message": "Todo deleted", "todo_id": str(todo_id)}
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete todo: {e}"
)