待办:项目待办模型 + /projects/{id}/todos 接口 + 控制台待办页

This commit is contained in:
host committed 2026-10-30 05:11:06 +08:00
1 parent c10fda4e4a
commit b3b3b35042
8 files changed
+962 -23

No files matched your search

+323
View File
@@ -0,0 +1,323 @@
"""项目待办(对齐 Muse 的待办功能)。
Muse 的待办是会话内工作清单 + 持久跟踪项:每项有状态
pending / in_progress / blocked / completed / cancelled,
执行中推进状态、完成即关闭、受阻要标原因。codex-app-server
此前没有这套模型(只有任务队列、协调员分工、goal 模式、
自动化与分诊,都不是可由用户逐项打勾的清单),本模块新加
一套项目级待办:
- 归属:一个待办属于一个项目(project_id),创建人记
created_by;读走项目 read,写走项目 create_task,与
自动化、技能等项目端点同款权限口径。
- 状态:与 Muse 的 todo.write 同款五态:pending(待办)、
in_progress(进行中)、blocked(受阻)、completed
(已完成)、cancelled(已取消)。completed 时记
completed_at,离开 completed 时清掉它;cancelled 是
软关闭,行保留可查,硬删除走 DELETE。
- 其它字段:title 必填、description 可选、priority
low / medium / high(默认 medium)、task_id 可选
(关联到同项目的一个任务,只存 id、不级联、不校验
任务存在性之外的语义——任务删了待办不跟着消失)、
due_at 可选(epoch 秒,>=1e11 按毫秒除 1000,与
自动化 first_run_at 同款容忍)、sort_order 可选
(默认追加到末尾,列表按 sort_order、created_at 排)。
- 与 Muse 的一点有意差异:Muse 会话清单习惯只留一个
in_progress(当前正在做的事),项目待办板不强制这条——
一个项目里多人并行,多项 in_progress 是正常态,强制
单一会把板子变成单人队列,所以这里不做互斥。
本模块只依赖标准库,不 import cloud 其它模块,避免循环
import;表迁移写法仿 automations(CREATE TABLE IF NOT
EXISTS + 查列补齐,SQLite 与 Postgres 适配层同款:调用方
仍写 ? 占位符,db.py 适配层转 PG 方言)。
"""
import time
import uuid
STATUSES = ("pending", "in_progress", "blocked", "completed", "cancelled")
PRIORITIES = ("low", "medium", "high")
MAX_TITLE_LEN = 500
MAX_DESCRIPTION_LEN = 20000
TODO_COLUMNS = (
"id", "project_id", "title", "description", "status", "priority",
"task_id", "due_at", "sort_order", "created_by", "created_at",
"updated_at", "completed_at")
def row_get(row, name):
try:
return row[name]
except (KeyError, IndexError, TypeError):
return None
def _row_dict(row, columns):
if row is None or isinstance(row, dict):
return row
try:
row["id"]
return row
except TypeError:
return dict(zip(columns, row))
def _existing_columns(conn, table):
try:
return {row[1] for row in conn.execute(
"PRAGMA table_info(%s)" % table).fetchall()}
except Exception:
return set()
def ensure_todos_table(conn):
"""幂等建待办表,并补齐新增列。调用方在写成功后 commit
(server 的 handler 与自动化同款:建表后由业务写操作
一并 commit;只读列表也在 handler 里 commit 一次,
保证老库首次访问即建表)。"""
conn.execute(
"CREATE TABLE IF NOT EXISTS todos("
"id TEXT PRIMARY KEY, "
"project_id TEXT, "
"title TEXT, "
"description TEXT, "
"status TEXT, "
"priority TEXT, "
"task_id TEXT, "
"due_at DOUBLE PRECISION, "
"sort_order INTEGER, "
"created_by TEXT, "
"created_at DOUBLE PRECISION, "
"updated_at DOUBLE PRECISION, "
"completed_at DOUBLE PRECISION)"
)
existing = _existing_columns(conn, "todos")
for col, ddl in (
("description", "TEXT"),
("priority", "TEXT"),
("task_id", "TEXT"),
("due_at", "DOUBLE PRECISION"),
("sort_order", "INTEGER"),
("completed_at", "DOUBLE PRECISION")):
if col not in existing:
try:
conn.execute(
"ALTER TABLE todos ADD COLUMN %s %s" % (col, ddl))
except Exception:
pass
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_todos_project"
" ON todos(project_id, sort_order, created_at)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_todos_status"
" ON todos(project_id, status)")
def todo_to_dict(row):
row = _row_dict(row, TODO_COLUMNS)
return {
"id": row["id"],
"project_id": row["project_id"],
"title": row["title"],
"description": row_get(row, "description") or "",
"status": row["status"],
"priority": row_get(row, "priority") or "medium",
"task_id": row_get(row, "task_id"),
"due_at": row_get(row, "due_at"),
"sort_order": row_get(row, "sort_order"),
"created_by": row["created_by"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"completed_at": row_get(row, "completed_at"),
}
def _validate_title(title):
if not title or not isinstance(title, str) or not title.strip():
raise ValueError("title required")
title = title.strip()
if len(title) > MAX_TITLE_LEN:
raise ValueError("title too long")
return title
def _validate_description(description):
if description is None:
return ""
if not isinstance(description, str):
raise ValueError("description must be a string")
if len(description) > MAX_DESCRIPTION_LEN:
raise ValueError("description too long")
return description
def _validate_status(status):
if status is None:
return "pending"
if status not in STATUSES:
raise ValueError("status must be one of: " + ", ".join(STATUSES))
return status
def _validate_priority(priority):
if priority is None:
return "medium"
if priority not in PRIORITIES:
raise ValueError(
"priority must be one of: " + ", ".join(PRIORITIES))
return priority
def _validate_due_at(due_at):
if due_at is None or due_at == "":
return None
if isinstance(due_at, bool) or not isinstance(due_at, (int, float)):
raise ValueError("due_at must be an epoch timestamp in seconds")
due_at = float(due_at)
if due_at >= 1e11:
due_at = due_at / 1000.0
return due_at
def _validate_task_id(task_id):
if task_id is None or task_id == "":
return None
if not isinstance(task_id, str):
raise ValueError("task_id must be a string")
return task_id.strip() or None
def _next_sort_order(conn, project_id):
row = conn.execute(
"SELECT MAX(sort_order) FROM todos WHERE project_id = ?",
(project_id,)).fetchone()
try:
mx = row[0] if row else None
except (TypeError, IndexError, KeyError):
mx = None
return (mx + 1) if isinstance(mx, (int, float)) else 0
def create_todo(conn, project_id, created_by, title, description=None,
status=None, priority=None, task_id=None, due_at=None,
sort_order=None, now=None):
"""创建待办:校验失败抛 ValueError(调用方转 400)。
成功时 commit 并返回 dict。"""
title = _validate_title(title)
description = _validate_description(description)
status = _validate_status(status)
priority = _validate_priority(priority)
task_id = _validate_task_id(task_id)
due_at = _validate_due_at(due_at)
if sort_order is not None and (
isinstance(sort_order, bool)
or not isinstance(sort_order, int)):
raise ValueError("sort_order must be an integer")
if now is None:
now = time.time()
ensure_todos_table(conn)
if sort_order is None:
sort_order = _next_sort_order(conn, project_id)
todo_id = uuid.uuid4().hex
completed_at = now if status == "completed" else None
conn.execute(
"INSERT INTO todos(id, project_id, title, description, status,"
" priority, task_id, due_at, sort_order, created_by, created_at,"
" updated_at, completed_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?,"
" ?, ?, ?)",
(todo_id, project_id, title, description, status, priority,
task_id, due_at, sort_order, created_by, now, now, completed_at))
conn.commit()
return get_todo(conn, todo_id)
def list_todos(conn, project_id, status=None, q=None):
"""列出某项目待办,按 sort_order、created_at 排。status
与 q(标题/描述子串,不区分大小写)是服务端过滤;
过滤在 Python 里做,避免 SQLite LIKE 与 PG ILIKE 的
大小写语义差异。"""
ensure_todos_table(conn)
rows = conn.execute(
"SELECT * FROM todos WHERE project_id = ?"
" ORDER BY sort_order, created_at",
(project_id,)).fetchall()
out = []
ql = q.strip().lower() if isinstance(q, str) and q.strip() else None
for row in rows:
d = todo_to_dict(row)
if status and d["status"] != status:
continue
if ql and ql not in (d["title"] + " " + d["description"]).lower():
continue
out.append(d)
return out
def get_todo(conn, todo_id):
ensure_todos_table(conn)
row = conn.execute(
"SELECT * FROM todos WHERE id = ?", (todo_id,)).fetchone()
if row is None:
return None
return todo_to_dict(row)
def update_todo(conn, todo_id, fields):
"""PATCH 待办:只改 fields 里给出的字段,未给出的不动;
未知字段直接忽略(不因此 400)。status 进 completed
时记 completed_at,离开 completed 时清掉。行不存在
时返回 None;校验失败抛 ValueError。"""
ensure_todos_table(conn)
row = conn.execute(
"SELECT * FROM todos WHERE id = ?", (todo_id,)).fetchone()
if row is None:
return None
cur = todo_to_dict(row)
updates = {}
if "title" in fields:
updates["title"] = _validate_title(fields["title"])
if "description" in fields:
updates["description"] = _validate_description(fields["description"])
if "status" in fields:
updates["status"] = _validate_status(fields["status"])
if "priority" in fields:
updates["priority"] = _validate_priority(fields["priority"])
if "task_id" in fields:
updates["task_id"] = _validate_task_id(fields["task_id"])
if "due_at" in fields:
updates["due_at"] = _validate_due_at(fields["due_at"])
if "sort_order" in fields:
so = fields["sort_order"]
if so is not None and (isinstance(so, bool)
or not isinstance(so, int)):
raise ValueError("sort_order must be an integer")
updates["sort_order"] = so
if not updates:
return cur
now = time.time()
updates["updated_at"] = now
new_status = updates.get("status", cur["status"])
if new_status == "completed" and cur["status"] != "completed":
updates["completed_at"] = now
elif new_status != "completed" and cur["status"] == "completed":
updates["completed_at"] = None
cols = ", ".join("%s = ?" % k for k in updates)
conn.execute(
"UPDATE todos SET %s WHERE id = ?" % cols,
tuple(updates.values()) + (todo_id,))
conn.commit()
return get_todo(conn, todo_id)
def delete_todo(conn, todo_id):
"""硬删除:行不存在返回 None,否则返回被删的 dict
(调用方先取行再删,保证 404 语义与审计都有据)。"""
ensure_todos_table(conn)
cur = get_todo(conn, todo_id)
if cur is None:
return None
conn.execute("DELETE FROM todos WHERE id = ?", (todo_id,))
conn.commit()
return cur