"""项目待办(对齐 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 是正常态,强制 单一会把板子变成单人队列,所以这里不做互斥。 本模块除行取值复用 cloud.peer_messages(该模块本身只依赖 标准库,故不产生循环 import)外不 import cloud 其它模块; 表迁移写法仿 automations(CREATE TABLE IF NOT EXISTS + 查列补齐,SQLite 与 Postgres 适配层同款:调用方 仍写 ? 占位符,db.py 适配层转 PG 方言)。 """ import time import uuid from cloud.peer_messages import row_get 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_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