From c016bd664e5ecc0fd35d1adbcabb9343b99a87dc Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 2 Jun 2026 19:16:45 -0400 Subject: [PATCH] fix(status-enum): add paused to ProjectStatus, validate, fix progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drift-audit Group 6 + Group 5 #6 (enum-extension / status drift): - ProjectStatus gains 'paused' — routes and frontend already treated it as first-class, but the enum (the source of truth) omitted it and the error strings lied. A future CHECK derived from the enum would have rejected existing paused rows. - create_project/update_project now validate status via ProjectStatus at the service layer (canonical gate; notes.status has no DB CHECK), so the MCP create/update_project path can't persist a typo'd status. MCP docstrings realigned to the 4-value domain; route error strings corrected. - get_milestone_progress: cancelled tasks are excluded from the percent denominator (and now reported in status_counts), so a milestone whose only open task was cancelled reaches 100% instead of stalling below it. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/fabledassistant/mcp/tools/projects.py | 6 +++--- src/fabledassistant/models/project.py | 1 + src/fabledassistant/routes/projects.py | 4 ++-- src/fabledassistant/services/milestones.py | 8 +++++++- src/fabledassistant/services/projects.py | 19 ++++++++++++++++++- 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/fabledassistant/mcp/tools/projects.py b/src/fabledassistant/mcp/tools/projects.py index 422a11a..73e1e28 100644 --- a/src/fabledassistant/mcp/tools/projects.py +++ b/src/fabledassistant/mcp/tools/projects.py @@ -27,7 +27,7 @@ from fabledassistant.services import trash as trash_svc async def list_projects() -> dict: """List all Scribe projects for the current user. - Returns id, title, description, goal, status (active/archived), color, + Returns id, title, description, goal, status (active/paused/completed/archived), color, and a short auto-generated summary for each project. """ uid = current_user_id() @@ -142,7 +142,7 @@ async def create_project( title: Project name (required). description: Short summary of what the project is. goal: The desired outcome or definition of done for the project. - status: active (default) or archived. + status: one of active (default), paused, completed, archived. color: Optional hex colour for the project card (e.g. "#6366f1"). """ uid = current_user_id() @@ -172,7 +172,7 @@ async def update_project( title: New title, or omit to leave unchanged. description: New description, or omit to leave unchanged. goal: New goal/definition-of-done, or omit to leave unchanged. - status: New status — active or archived. + status: New status — one of active, paused, completed, archived. color: New hex colour, or omit to leave unchanged. """ uid = current_user_id() diff --git a/src/fabledassistant/models/project.py b/src/fabledassistant/models/project.py index cfdf900..ccf4db5 100644 --- a/src/fabledassistant/models/project.py +++ b/src/fabledassistant/models/project.py @@ -8,6 +8,7 @@ from fabledassistant.models.base import TimestampMixin, SoftDeleteMixin class ProjectStatus(str, enum.Enum): active = "active" + paused = "paused" completed = "completed" archived = "archived" diff --git a/src/fabledassistant/routes/projects.py b/src/fabledassistant/routes/projects.py index dd10f9d..f1c65e5 100644 --- a/src/fabledassistant/routes/projects.py +++ b/src/fabledassistant/routes/projects.py @@ -53,7 +53,7 @@ async def create_project_route(): return jsonify({"error": "title is required"}), 400 status = data.get("status", "active") if status not in ("active", "paused", "completed", "archived"): - return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400 + return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400 project = await create_project( uid, title=data["title"], @@ -90,7 +90,7 @@ async def update_project_route(project_id: int): allowed = {"title", "description", "goal", "status", "color"} fields = {k: (v if v is not None else "") for k, v in data.items() if k in allowed} if "status" in fields and fields["status"] not in ("active", "paused", "completed", "archived"): - return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400 + return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400 project = await update_project(uid, project_id, **fields) if project is None: return not_found("Project") diff --git a/src/fabledassistant/services/milestones.py b/src/fabledassistant/services/milestones.py index dd0c796..fab1b98 100644 --- a/src/fabledassistant/services/milestones.py +++ b/src/fabledassistant/services/milestones.py @@ -155,8 +155,13 @@ async def get_milestone_progress(milestone_id: int) -> dict: status_counts[status] = count total = sum(status_counts.values()) + cancelled = status_counts.get("cancelled", 0) completed = status_counts.get("done", 0) - pct = round(completed / total * 100, 1) if total > 0 else 0.0 + # Cancelled tasks are resolved work, not pending — exclude them from the + # percent-complete denominator so a milestone whose only open task was + # cancelled still reaches 100% (and auto-collapses) instead of stalling. + active_total = total - cancelled + pct = round(completed / active_total * 100, 1) if active_total > 0 else 0.0 return { "total": total, @@ -166,6 +171,7 @@ async def get_milestone_progress(milestone_id: int) -> dict: "todo": status_counts.get("todo", 0), "in_progress": status_counts.get("in_progress", 0), "done": status_counts.get("done", 0), + "cancelled": cancelled, }, } diff --git a/src/fabledassistant/services/projects.py b/src/fabledassistant/services/projects.py index 477f65b..3b7ed74 100644 --- a/src/fabledassistant/services/projects.py +++ b/src/fabledassistant/services/projects.py @@ -6,11 +6,25 @@ from sqlalchemy import func, select from fabledassistant.models import async_session from fabledassistant.models.note import Note -from fabledassistant.models.project import Project +from fabledassistant.models.project import Project, ProjectStatus logger = logging.getLogger(__name__) +def _validate_status(status: str) -> str: + """Coerce/validate a project status against ProjectStatus. + + Canonical gate so the MCP create/update_project path (which passes status + straight through) can't persist an out-of-enum value — there's no DB CHECK. + """ + try: + return ProjectStatus(status).value + except ValueError: + raise ValueError( + f"Invalid status: {status!r}. Must be one of: {[s.value for s in ProjectStatus]}" + ) + + async def create_project( user_id: int, title: str, @@ -19,6 +33,7 @@ async def create_project( color: str | None = None, status: str = "active", ) -> Project: + status = _validate_status(status) async with async_session() as session: project = Project( user_id=user_id, @@ -87,6 +102,8 @@ async def update_project(user_id: int, project_id: int, **fields: object) -> Pro project = result.scalars().first() if project is None: return None + if "status" in fields and fields["status"] is not None: + fields["status"] = _validate_status(fields["status"]) for key, value in fields.items(): if hasattr(project, key): setattr(project, key, value)