luplo.core.tasks ================ .. py:module:: luplo.core.tasks .. autoapi-nested-parse:: Task domain — a thin wrapper around items where ``item_type='task'``. Tasks live in ``items`` (D3 — no separate table). Each state transition creates a new row that supersedes the previous one (standard supersede pattern); ``reorder_tasks`` is the one exception, which updates ``context.sort_order`` in place per P10. User-facing task identity = supersede-chain head. Mutating helpers accept *any* id in the chain and resolve to the head before acting; the returned ``Item`` is the new head. Concurrency (P7): no DB-level UNIQUE for in-progress tasks. ``start_task`` takes a ``SELECT … FOR UPDATE`` on the candidate "current in-progress" row(s) per work unit and raises ``TaskAlreadyInProgressError`` if any exists. All start paths must go through this function. Attributes ---------- .. autoapisummary:: luplo.core.tasks.ITEM_TYPE Functions --------- .. autoapisummary:: luplo.core.tasks.create_task luplo.core.tasks.get_task luplo.core.tasks.list_tasks luplo.core.tasks.get_in_progress_task luplo.core.tasks.start_task luplo.core.tasks.complete_task luplo.core.tasks.block_task luplo.core.tasks.skip_task luplo.core.tasks.suggest_decision_from_task luplo.core.tasks.edit_task luplo.core.tasks.reorder_tasks Module Contents --------------- .. py:data:: ITEM_TYPE :value: 'task' .. py:function:: create_task(conn: psycopg.AsyncConnection[Any], *, project_id: str, work_unit_id: str, title: str, actor_id: str, sort_order: int | None = None, systems: list[str] | None = None, body: str | None = None, context_extra: dict[str, Any] | None = None) -> luplo.core.models.Item :async: Create a new task in ``proposed`` status. If *sort_order* is None, it defaults to ``max(sort_order) + 10`` for the work unit (gap-strategy starting at 10). .. py:function:: get_task(conn: psycopg.AsyncConnection[Any], task_id: str, *, project_id: str | None = None) -> luplo.core.models.Item | None :async: Fetch the head of the chain containing *task_id*. Accepts a full UUID or a hex prefix (≥8 chars). Returns ``None`` if no row matches; raises :class:`AmbiguousIdError` if a prefix matches distinct chains. .. py:function:: list_tasks(conn: psycopg.AsyncConnection[Any], work_unit_id: str, *, status: str | None = None) -> list[luplo.core.models.Item] :async: List the heads of every task chain in *work_unit_id*. Ordered by ``sort_order`` ASC, then ``created_at``. .. py:function:: get_in_progress_task(conn: psycopg.AsyncConnection[Any], work_unit_id: str) -> luplo.core.models.Item | None :async: Return the single in_progress task head for *work_unit_id*, or ``None``. .. py:function:: start_task(conn: psycopg.AsyncConnection[Any], task_id: str, *, actor_id: str, project_id: str | None = None) -> luplo.core.models.Item :async: Transition a task to ``in_progress``. P7: enforces the "at most one in_progress per work_unit" invariant in the domain layer (no DB UNIQUE) by taking a row-level lock on any candidate in_progress heads in the same work unit. Passing *project_id* scopes prefix resolution to a single project, so an ``abc12345`` prefix that happens to match a task in a different project can never silently mutate that other row. :raises TaskNotFoundError, TaskStateTransitionError, TaskAlreadyInProgressError.: .. py:function:: complete_task(conn: psycopg.AsyncConnection[Any], task_id: str, *, actor_id: str, summary: str | None = None, project_id: str | None = None) -> luplo.core.models.Item :async: Transition a task to ``done``. Pass *project_id* to scope prefix resolution to a single project. .. py:function:: block_task(conn: psycopg.AsyncConnection[Any], task_id: str, *, actor_id: str, reason: str, project_id: str | None = None) -> luplo.core.models.Item :async: Transition a task to ``blocked`` with a reason. The associated decision item is created by ``LocalBackend.block_task`` (cross-cutting), not here. Pass *project_id* to scope prefix resolution to a single project. .. py:function:: skip_task(conn: psycopg.AsyncConnection[Any], task_id: str, *, actor_id: str, reason: str | None = None, project_id: str | None = None) -> luplo.core.models.Item :async: Transition a task to ``skipped``. Pass *project_id* to scope prefix resolution to a single project. .. py:function:: suggest_decision_from_task(conn: psycopg.AsyncConnection[Any], task_id: str, *, project_id: str | None = None) -> luplo.core.models.ItemCreate | None :async: Build a draft ``decision`` item from a task without inserting it. Intended for the "on ``task done`` propose a decision" flow. The draft is returned to the caller; nothing is written to the database. Inserting the draft remains an explicit user step — the augment-not-replace commitment in the philosophy doc forbids the tool from deciding *this moment is the moment to save a decision*. Returns ``None`` when the task lacks enough content to support a meaningful draft (no body, no summary, default title). Returning a template in that case would be a confident guess dressed as a suggestion — honesty-over-coverage says no. :param conn: Async psycopg connection. :param task_id: Any ID in the task's supersede chain. :param project_id: Optional project scope for prefix resolution. :returns: An :class:`ItemCreate` ready to be shown to the user, or ``None`` when there is nothing worth suggesting. :raises TaskNotFoundError: If the prefix does not resolve to a task. .. py:function:: edit_task(conn: psycopg.AsyncConnection[Any], task_id: str, *, actor_id: str, title: str | None = None, body: str | None = None, sort_order: int | None = None, project_id: str | None = None) -> luplo.core.models.Item :async: Edit a task's title / body / sort_order by creating a supersede row. The status machine is preserved — an edit never transitions a task between ``proposed`` / ``in_progress`` / ``done`` / ``blocked`` / ``skipped``. Changing status is the job of the dedicated :func:`start_task`, :func:`complete_task`, and similar verbs. Pass only the fields you want to change; the rest are copied from the current head. Passing no changeable field is a no-op that still returns the current head unchanged (useful when the caller only wants to revalidate existence). :param conn: Async psycopg connection. :param task_id: Full UUID or hex prefix of any row in the task's supersede chain. :param actor_id: Who is performing the edit. :param title: New title, or ``None`` to keep the current one. :param body: New body, or ``None`` to keep the current one. :param sort_order: New sort_order, or ``None`` to keep the current one. :param project_id: Optional project scope for prefix resolution. :returns: The new head row (or the unchanged head when no fields changed). :raises TaskNotFoundError: If the prefix does not resolve to a task. :raises AmbiguousIdError: If the prefix matches multiple distinct heads. .. py:function:: reorder_tasks(conn: psycopg.AsyncConnection[Any], work_unit_id: str, task_ids: list[str], *, actor_id: str, project_id: str | None = None) -> list[luplo.core.models.Item] :async: Set sort_order for a list of tasks (gap-10 strategy). Per P10, this is an in-place UPDATE: no new supersede rows, no items_history entries. The audit trail is a single ``item.update`` entry recorded by the LocalBackend wrapper. *task_ids* may include any id in each chain — they are resolved to heads internally. The returned list mirrors the input order with the refreshed head rows. Raises ``TaskNotFoundError`` if any id cannot be resolved or its head does not belong to *work_unit_id*.