320 lines
11 KiB
Python
320 lines
11 KiB
Python
"""项目待办(对齐 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
|