Files
codex-app-server/cloud/todos.py
T

324 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""项目待办(对齐 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