kin: KIN-127-backend_dev
This commit is contained in:
parent
a22cf738b7
commit
b431d9358a
2 changed files with 84 additions and 5 deletions
|
|
@ -13,7 +13,7 @@ from typing import Any
|
||||||
|
|
||||||
VALID_TASK_STATUSES = [
|
VALID_TASK_STATUSES = [
|
||||||
"pending", "in_progress", "review", "done",
|
"pending", "in_progress", "review", "done",
|
||||||
"blocked", "decomposed", "cancelled",
|
"blocked", "decomposed", "cancelled", "revising",
|
||||||
]
|
]
|
||||||
|
|
||||||
VALID_COMPLETION_MODES = {"auto_complete", "review"}
|
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)
|
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({
|
VALID_TASK_SORT_FIELDS = frozenset({
|
||||||
"updated_at", "created_at", "priority", "status", "title", "id",
|
"updated_at", "created_at", "priority", "status", "title", "id",
|
||||||
})
|
})
|
||||||
|
|
@ -275,14 +317,16 @@ def list_tasks(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
project_id: str | None = None,
|
project_id: str | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
|
parent_task_id: str | None = None,
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
sort: str = "updated_at",
|
sort: str = "updated_at",
|
||||||
sort_dir: str = "desc",
|
sort_dir: str = "desc",
|
||||||
) -> list[dict]:
|
) -> 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: column name (validated against VALID_TASK_SORT_FIELDS, default 'updated_at')
|
||||||
sort_dir: 'asc' or 'desc' (default 'desc')
|
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
|
# Validate sort field to prevent SQL injection
|
||||||
sort_col = sort if sort in VALID_TASK_SORT_FIELDS else "updated_at"
|
sort_col = sort if sort in VALID_TASK_SORT_FIELDS else "updated_at"
|
||||||
|
|
@ -296,6 +340,11 @@ def list_tasks(
|
||||||
if status:
|
if status:
|
||||||
query += " AND status = ?"
|
query += " AND status = ?"
|
||||||
params.append(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}"
|
query += f" ORDER BY {sort_col} {sort_direction}"
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
query += " LIMIT ?"
|
query += " LIMIT ?"
|
||||||
|
|
@ -304,7 +353,11 @@ def list_tasks(
|
||||||
|
|
||||||
|
|
||||||
def update_task(conn: sqlite3.Connection, id: str, **fields) -> dict:
|
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:
|
if not fields:
|
||||||
return get_task(conn, id)
|
return get_task(conn, id)
|
||||||
json_cols = ("brief", "spec", "review", "test_result", "security_result", "labels")
|
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:
|
if key in fields:
|
||||||
fields[key] = _json_encode(fields[key])
|
fields[key] = _json_encode(fields[key])
|
||||||
if "status" in fields and fields["status"] == "done":
|
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()
|
fields["updated_at"] = datetime.now().isoformat()
|
||||||
sets = ", ".join(f"{k} = ?" for k in fields)
|
sets = ", ".join(f"{k} = ?" for k in fields)
|
||||||
vals = list(fields.values()) + [id]
|
vals = list(fields.values()) + [id]
|
||||||
conn.execute(f"UPDATE tasks SET {sets} WHERE id = ?", vals)
|
conn.execute(f"UPDATE tasks SET {sets} WHERE id = ?", vals)
|
||||||
conn.commit()
|
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)
|
return get_task(conn, id)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
20
web/api.py
20
web/api.py
|
|
@ -660,14 +660,19 @@ def list_tasks(
|
||||||
limit: int = Query(default=20, ge=1, le=500),
|
limit: int = Query(default=20, ge=1, le=500),
|
||||||
sort: str = Query(default="updated_at"),
|
sort: str = Query(default="updated_at"),
|
||||||
project_id: str | None = Query(default=None),
|
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
|
from core.models import VALID_TASK_SORT_FIELDS
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
tasks = models.list_tasks(
|
tasks = models.list_tasks(
|
||||||
conn,
|
conn,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
status=status,
|
status=status,
|
||||||
|
parent_task_id=parent_task_id,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
sort=sort if sort in VALID_TASK_SORT_FIELDS else "updated_at",
|
sort=sort if sort in VALID_TASK_SORT_FIELDS else "updated_at",
|
||||||
sort_dir="desc",
|
sort_dir="desc",
|
||||||
|
|
@ -686,6 +691,19 @@ def get_task(task_id: str):
|
||||||
return t
|
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):
|
class TaskCreate(BaseModel):
|
||||||
project_id: str
|
project_id: str
|
||||||
title: str
|
title: str
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue