Files
FabledScribe/src/scribe/routes/tasks.py
T
bvandeusenandClaude Opus 5 46d9134b10
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 57s
CI & Build / Python tests (push) Successful in 1m30s
CI & Build / Build & push image (push) Successful in 27s
feat(409): a task write returns where the task sits (#4010)
Step 1 of milestone 409 "Response shapes". An agent reporting finished work
is asked to say which milestone it belongs to, which step of how many, and
what is next. Without those facts to hand it reconstructs them, and a
reconstruction reads exactly like the truth when it is wrong.

create_task and update_task (MCP) and the REST create/update task routes now
return a placement block: project; and for a task in a milestone, the
milestone, position {step, of}, progress {completed, total, pct} and next
(the next open step, falling back to the earliest open one before it).

- Step order is creation order, not get_milestone listing order, which
  reshuffles on every update.
- Siblings are read through readable_notes_clause; position and progress
  are computed over that same readable set, so a collaborator is never shown
  a step title they cannot open, and a note share alone reveals no plan.
- Fail-open and omitted when empty, like every in-band decoration.
- _no_embedding moves into conftest as one opt-in fixture for both
  integration modules that need it.

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

340 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 placement as placement_svc
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)]
await placement_svc.attach_placement(uid, out, task)
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)]
await placement_svc.attach_placement(uid, out, task)
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