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
`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>
337 lines
12 KiB
Python
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
|
|
|
|
|