From b431d9358a007ffbf312537bc40615b58aef144c Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Wed, 18 Mar 2026 20:55:35 +0200 Subject: [PATCH] kin: KIN-127-backend_dev --- core/models.py | 69 +++++++++++++++++++++++++++++++++++++++++++++++--- web/api.py | 20 ++++++++++++++- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/core/models.py b/core/models.py index 6d004f3..b21b1af 100644 --- a/core/models.py +++ b/core/models.py @@ -13,7 +13,7 @@ from typing import Any VALID_TASK_STATUSES = [ "pending", "in_progress", "review", "done", - "blocked", "decomposed", "cancelled", + "blocked", "decomposed", "cancelled", "revising", ] VALID_COMPLETION_MODES = {"auto_complete", "review"} @@ -266,6 +266,48 @@ def get_task(conn: sqlite3.Connection, id: str) -> dict | None: return _row_to_dict(row) +def get_children(conn: sqlite3.Connection, task_id: str) -> list[dict]: + """Return direct child tasks of given task_id.""" + rows = conn.execute( + "SELECT * FROM tasks WHERE parent_task_id = ?", (task_id,) + ).fetchall() + return _rows_to_list(rows) + + +def has_open_children(conn: sqlite3.Connection, task_id: str) -> bool: + """Recursively check if task has any open (not done/cancelled) descendants.""" + children = get_children(conn, task_id) + for child in children: + if child["status"] not in ("done", "cancelled"): + return True + if has_open_children(conn, child["id"]): + return True + return False + + +def _check_parent_completion(conn: sqlite3.Connection, task_id: str) -> None: + """Cascade-check upward: if parent is 'revising' and all children closed → promote to 'done'.""" + task = get_task(conn, task_id) + if not task: + return + parent_id = task.get("parent_task_id") + if not parent_id: + return + parent = get_task(conn, parent_id) + if not parent: + return + if parent["status"] != "revising": + return + if not has_open_children(conn, parent_id): + now = datetime.now().isoformat() + conn.execute( + "UPDATE tasks SET status = 'done', completed_at = ?, updated_at = ? WHERE id = ?", + (now, now, parent_id), + ) + conn.commit() + _check_parent_completion(conn, parent_id) + + VALID_TASK_SORT_FIELDS = frozenset({ "updated_at", "created_at", "priority", "status", "title", "id", }) @@ -275,14 +317,16 @@ def list_tasks( conn: sqlite3.Connection, project_id: str | None = None, status: str | None = None, + parent_task_id: str | None = None, limit: int | None = None, sort: str = "updated_at", sort_dir: str = "desc", ) -> list[dict]: - """List tasks with optional project/status filters, limit, and sort. + """List tasks with optional project/status/parent filters, limit, and sort. sort: column name (validated against VALID_TASK_SORT_FIELDS, default 'updated_at') sort_dir: 'asc' or 'desc' (default 'desc') + parent_task_id: filter by parent task id (pass '__none__' to get root tasks) """ # Validate sort field to prevent SQL injection sort_col = sort if sort in VALID_TASK_SORT_FIELDS else "updated_at" @@ -296,6 +340,11 @@ def list_tasks( if status: query += " AND status = ?" params.append(status) + if parent_task_id == "__none__": + query += " AND parent_task_id IS NULL" + elif parent_task_id: + query += " AND parent_task_id = ?" + params.append(parent_task_id) query += f" ORDER BY {sort_col} {sort_direction}" if limit is not None: query += " LIMIT ?" @@ -304,7 +353,11 @@ def list_tasks( def update_task(conn: sqlite3.Connection, id: str, **fields) -> dict: - """Update task fields. Auto-sets updated_at. Sets completed_at when status transitions to 'done'.""" + """Update task fields. Auto-sets updated_at. Sets completed_at when status transitions to 'done'. + + If transitioning to 'done' but task has open children, sets 'revising' instead. + After a child closes (done/cancelled), cascades upward to promote revising parents. + """ if not fields: return get_task(conn, id) json_cols = ("brief", "spec", "review", "test_result", "security_result", "labels") @@ -312,12 +365,20 @@ def update_task(conn: sqlite3.Connection, id: str, **fields) -> dict: if key in fields: fields[key] = _json_encode(fields[key]) if "status" in fields and fields["status"] == "done": - fields["completed_at"] = datetime.now().isoformat() + if has_open_children(conn, id): + fields["status"] = "revising" + # Do NOT set completed_at for revising (Decision #737) + else: + fields["completed_at"] = datetime.now().isoformat() fields["updated_at"] = datetime.now().isoformat() sets = ", ".join(f"{k} = ?" for k in fields) vals = list(fields.values()) + [id] conn.execute(f"UPDATE tasks SET {sets} WHERE id = ?", vals) conn.commit() + # Cascade upward: if this child just closed, maybe its parent can finish + new_status = fields.get("status") + if new_status in ("done", "cancelled"): + _check_parent_completion(conn, id) return get_task(conn, id) diff --git a/web/api.py b/web/api.py index c788acd..c8e31f5 100644 --- a/web/api.py +++ b/web/api.py @@ -660,14 +660,19 @@ def list_tasks( limit: int = Query(default=20, ge=1, le=500), sort: str = Query(default="updated_at"), project_id: str | None = Query(default=None), + parent_task_id: str | None = Query(default=None), ): - """List tasks with optional filters. sort defaults to updated_at desc.""" + """List tasks with optional filters. sort defaults to updated_at desc. + + parent_task_id: filter by parent task. Use '__none__' to get root tasks only. + """ from core.models import VALID_TASK_SORT_FIELDS conn = get_conn() tasks = models.list_tasks( conn, project_id=project_id, status=status, + parent_task_id=parent_task_id, limit=limit, sort=sort if sort in VALID_TASK_SORT_FIELDS else "updated_at", sort_dir="desc", @@ -686,6 +691,19 @@ def get_task(task_id: str): return t +@app.get("/api/tasks/{task_id}/children") +def get_task_children(task_id: str): + """Get direct child tasks of a given task.""" + conn = get_conn() + t = models.get_task(conn, task_id) + if not t: + conn.close() + raise HTTPException(404, f"Task '{task_id}' not found") + children = models.get_children(conn, task_id) + conn.close() + return children + + class TaskCreate(BaseModel): project_id: str title: str