Files
FabledGrimoire/src/scribe/routes/tasks.py
T
bvandeusenandClaude Opus 5 69d93898d9
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 48s
CI & Build / Build & push image (push) Skipped
fix(tasks): a task's kind is correctable — the Kind select stops lying (#3129)
`kind` was accepted at CREATE on both doors and dropped at UPDATE on both:
update_task had no such parameter, and the REST PATCH allow-list never read
the field. So a task filed under the wrong kind could never be corrected.

The frontend made it worse by looking like it worked. TaskEditorView binds a
Kind select, marks the form dirty, and HAS ALWAYS SENT `kind` in the update
payload — the store even types it. The route ignored it, returned 200, the
view optimistically updated, the toast said "Task saved", and the old value
came back on reload. Silent success, same class as #2709.

Found by trying to re-file #3126 as a spike after deploying 0091. It could
not be done; the task had to be recreated as #3128 and the original
cancelled.

One seam, not two doors. `minted_kind()` lives in services/notes.py because
the REST route cannot import an MCP tool module and a second spelling of the
list is how the doors would come to disagree. Both create and update route
through it, so a bogus kind is now a readable error rather than a
CheckViolationError surfacing as a 500.

TaskKind joins TaskStatus and TaskPriority as a real enum, and update_note
validates task_kind exactly as it already validated those two — the field
had been reaching setattr through the hasattr guard with no validation at
all, unnoticed only because no door ever offered it.

The `-> plan` question #3129 raised is answered in code rather than left
implicit: MINTABLE_KINDS is work/issue/spike, deliberately NARROWER than the
column's CHECK. `plan` stays a valid stored value because historical
plan-tasks carry it and must stay writable; it is simply not a value any
door hands out, and the refusal names start_planning because a caller
reaching for it wants a plan. The whitelist and the policy answer different
questions and are not the same list.

Every new test reads the value BACK. One that only asserted the call
succeeded would have passed against the broken code — the route returned 200
while dropping the field, which is how this survived long enough to be found
by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 15:55:04 -04:00

337 lines
12 KiB
Python

from datetime import date
from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id
from scribe.models.note import TaskPriority, TaskStatus
from scribe.routes.utils import not_found, parse_iso_date, parse_pagination
from scribe.services.access import can_write_note
from scribe.services import systems as systems_svc
from scribe.services.notes import (
create_note,
get_note_for_user,
list_notes,
update_note,
)
from scribe.services.notes import minted_kind as notes_minted_kind
from scribe.services.note_usage import record_pulled
from scribe.services.planning import start_planning as svc_start_planning
from scribe.services.recurrence import calculate_next_due, validate_recurrence_rule
tasks_bp = Blueprint("tasks", __name__, url_prefix="/api/tasks")
_UNSET = object()
def _parse_recurrence_rule(data: dict) -> tuple[object, tuple | None]:
"""Extract and validate recurrence_rule from request data.
Returns (rule_or_UNSET, error_response_or_None):
_UNSET → key not present, do nothing
None → key present and null, clear the rule
dict → key present and valid, set the rule
"""
if "recurrence_rule" not in data:
return _UNSET, None
rule = data["recurrence_rule"]
if rule is None:
return None, None
try:
validate_recurrence_rule(rule)
except ValueError as exc:
return _UNSET, (jsonify({"error": str(exc)}), 400)
return rule, None
@tasks_bp.route("", methods=["GET"])
@login_required
async def list_tasks_route():
uid = get_current_user_id()
q = request.args.get("q")
tag = request.args.getlist("tag")
status = request.args.getlist("status") or None
priority = request.args.getlist("priority") or None
sort = request.args.get("sort", "updated_at")
order = request.args.get("order", "desc")
limit, offset = parse_pagination()
project_id = request.args.get("project_id", type=int)
no_project = request.args.get("no_project", "").lower() == "true"
kind = request.args.get("kind") or None
due_before = parse_iso_date(request.args.get("due_before"), "due_before")
if isinstance(due_before, tuple):
return due_before
due_after = parse_iso_date(request.args.get("due_after"), "due_after")
if isinstance(due_after, tuple):
return due_after
tasks, total = await list_notes(
uid,
q=q,
tags=tag or None,
is_task=True,
status=status,
priority=priority,
due_before=due_before,
due_after=due_after,
project_id=project_id,
task_kind=kind,
no_project=no_project,
sort=sort,
order=order,
limit=limit,
offset=offset,
)
return jsonify({"tasks": [t.to_dict() for t in tasks], "total": total})
@tasks_bp.route("", methods=["POST"])
@login_required
async def create_task_route():
uid = get_current_user_id()
data = await request.get_json()
# Description (user-stated goal) and body (machine-maintained summary) are
# separate fields under the task-as-durable-record design. Don't fold one
# into the other.
body = data.get("body", "")
description = data.get("description")
tags = data.get("tags", [])
# kind=plan is retired — plans are milestones now (see start_planning).
# The 'plan' enum value stays valid for legacy tasks, but new ones can't
# be created with it through any path.
if data.get("kind") == "plan":
return jsonify({
"error": "kind=plan is retired — plans are milestones. "
"Use POST /api/tasks/planning to start a plan."
}), 400
due_date = parse_iso_date(data.get("due_date"), "due_date")
if isinstance(due_date, tuple):
return due_date
try:
status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value
except ValueError:
return jsonify({"error": f"Invalid status: {data['status']}"}), 400
try:
priority = TaskPriority(data["priority"]).value if "priority" in data else TaskPriority.none.value
except ValueError:
return jsonify({"error": f"Invalid priority: {data['priority']}"}), 400
recurrence_rule, recurrence_err = _parse_recurrence_rule(data)
if recurrence_err:
return recurrence_err
project_id = data.get("project_id")
if project_id is None and data.get("project"):
from scribe.services.projects import get_project_by_title as _gpbt
proj = await _gpbt(uid, data["project"])
if proj:
project_id = proj.id
task = await create_note(
uid,
title=data.get("title", ""),
body=body,
description=description,
status=status,
priority=priority,
due_date=due_date,
tags=tags,
project_id=project_id,
milestone_id=data.get("milestone_id"),
parent_id=data.get("parent_id"),
recurrence_rule=recurrence_rule if recurrence_rule is not _UNSET else None,
task_kind=data.get("kind", "work"),
arose_from_id=data.get("arose_from_id"),
)
if data.get("system_ids") is not None:
await systems_svc.set_record_systems(uid, task.id, data["system_ids"])
out = task.to_dict()
out["systems"] = [s.to_dict() for s in await systems_svc.list_record_systems(uid, task.id)]
return jsonify(out), 201
@tasks_bp.route("/planning", methods=["POST"])
@login_required
async def start_planning_route():
uid = get_current_user_id()
data = await request.get_json() or {}
project_id = data.get("project_id")
title = (data.get("title") or "").strip()
if not project_id or not title:
return jsonify({"error": "project_id and title are required"}), 400
try:
result = await svc_start_planning(
user_id=uid, project_id=int(project_id), title=title,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify(result), 201
@tasks_bp.route("/<int:task_id>", methods=["GET"])
@login_required
async def get_task_route(task_id: int):
uid = get_current_user_id()
result = await get_note_for_user(uid, task_id)
if result is None:
return not_found("Task")
task, permission = result
data = task.to_dict()
data["permission"] = permission
if task.parent_id:
# Share-aware like the task itself: a shared subtask whose parent is also
# shared would otherwise render as an orphan with no parent title.
parent = await get_note_for_user(uid, task.parent_id)
data["parent_title"] = parent[0].title if parent else None
data["systems"] = [s.to_dict() for s in await systems_svc.list_record_systems(uid, task_id)]
# Opening the detail view IS a pull — see the note beside rest_note in
# routes/notes.py for why the rest_* and mcp_* prefixes stay separable.
record_pulled(user_id=uid, note_id=task_id, source="rest_task")
return jsonify(data)
@tasks_bp.route("/<int:task_id>", methods=["PUT", "PATCH"])
@login_required
async def update_task_route(task_id: int):
uid = get_current_user_id()
result = await get_note_for_user(uid, task_id)
if result is None:
return not_found("Task")
task_note, _ = result
if not await can_write_note(uid, task_id):
return jsonify({"error": "Permission denied"}), 403
data = await request.get_json()
fields = {}
for key in ("title",):
if key in data:
fields[key] = data[key]
if "status" in data:
try:
fields["status"] = TaskStatus(data["status"]).value
except ValueError:
return jsonify({"error": f"Invalid status: {data['status']}"}), 400
if "priority" in data:
try:
fields["priority"] = TaskPriority(data["priority"]).value
except ValueError:
return jsonify({"error": f"Invalid priority: {data['priority']}"}), 400
# Body and description are distinct fields under the task-as-durable-record
# design. Don't alias one to the other.
if "body" in data:
fields["body"] = data["body"]
if "description" in data:
fields["description"] = data["description"]
if "due_date" in data:
if data["due_date"]:
result = parse_iso_date(data["due_date"], "due_date")
if isinstance(result, tuple):
return result
fields["due_date"] = result
else:
fields["due_date"] = None
if "tags" in data:
fields["tags"] = data["tags"]
# Re-filing a task's kind. The editor has always SENT this field and the
# route silently dropped it — reporting "Task saved" and reverting on the
# next load (#3129). Validated at the same layer as status and priority so
# an unrecognised value is a 400 rather than a database CHECK violation.
if "kind" in data:
try:
fields["task_kind"] = notes_minted_kind(data["kind"])
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
for key in ("project_id", "milestone_id", "parent_id", "arose_from_id"):
if key in data:
fields[key] = data[key]
recurrence_rule, recurrence_err = _parse_recurrence_rule(data)
if recurrence_err:
return recurrence_err
if recurrence_rule is not _UNSET:
fields["recurrence_rule"] = recurrence_rule
task = await update_note(task_note.user_id, task_id, **fields)
if task is None:
return not_found("Task")
if data.get("system_ids") is not None:
await systems_svc.set_record_systems(uid, task_id, data["system_ids"])
out = task.to_dict()
out["systems"] = [s.to_dict() for s in await systems_svc.list_record_systems(uid, task_id)]
return jsonify(out)
@tasks_bp.route("/<int:task_id>/status", methods=["PATCH"])
@login_required
async def patch_task_status(task_id: int):
uid = get_current_user_id()
result = await get_note_for_user(uid, task_id)
if result is None:
return not_found("Task")
task_note, _ = result
if not await can_write_note(uid, task_id):
return jsonify({"error": "Permission denied"}), 403
data = await request.get_json()
status_val = data.get("status")
if not status_val:
return jsonify({"error": "status is required"}), 400
try:
TaskStatus(status_val)
except ValueError:
return jsonify({"error": f"Invalid status: {status_val}"}), 400
task = await update_note(task_note.user_id, task_id, status=status_val)
if task is None:
return not_found("Task")
return jsonify(task.to_dict())
@tasks_bp.route("/<int:task_id>/recurrence-preview", methods=["GET"])
@login_required
async def recurrence_preview_route(task_id: int):
uid = get_current_user_id()
result = await get_note_for_user(uid, task_id)
if result is None:
return not_found("Task")
task, _ = result
if not task.recurrence_rule:
return jsonify({"error": "Task has no recurrence rule"}), 400
count = min(request.args.get("count", 5, type=int), 10)
base = task.due_date or date.today()
dates = []
current = base
for _ in range(count):
current = calculate_next_due(task.recurrence_rule, current)
dates.append(current.isoformat())
return jsonify({"dates": dates})
@tasks_bp.route("/<int:task_id>", methods=["DELETE"])
@login_required
async def delete_task_route(task_id: int):
uid = get_current_user_id()
result = await get_note_for_user(uid, task_id)
if result is None:
return not_found("Task")
task_note, _ = result
if not await can_write_note(uid, task_id):
return jsonify({"error": "Permission denied"}), 403
from scribe.services.trash import delete as trash_delete
batch = await trash_delete(task_note.user_id, "task", task_id)
if batch is None:
return not_found("Task")
return "", 204