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