feat: P1 补齐(自动评审队列、自动化+分诊、并发 caps、运行时审批队列)

This commit is contained in:
host committed 2026-10-27 15:53:50 +08:00
1 parent 29efc6c61f
commit e0cecd70aa
18 files changed
+3474 -27

No files matched your search

+302
View File
@@ -0,0 +1,302 @@
"""运行时审批队列(细粒度审批,P1 第 4 项)。
选型:只打通 elicitation 一类,其他审批类全部记为遗留。
选 elicitation 的理由:
- 它是 host.py 里唯一已经特判的 server_request 类,未来 hook
的落点就是 _answer_server_request 的 elicitation 分支,加法
可接,不用先修一条错的通用回包。
- 决定语义最干净:二值,批准 = accept(content 为空对象,
与今天 approval=auto 时的行为完全一致),拒绝 = decline,
不需要像 permissions 那样现拼权限 profile,也不需要像
file_change 的 cancel 那样附带“中断整轮”的副作用。
- 触发方是 MCP 服务器向用户要表单输入,正是“第三方工具向
用户要东西”的场景,用户在控制台点允许或拒绝最符合直觉。
关键语义(先定清楚,再写代码):
- 待审批期间原流程挂起等待,不先拒绝后补。队列里的 pending
项本身不做任何放行或拦截动作,它只是等一个决定。
- 等待有超时:默认 3600 秒,可在创建时改(服务端入口只接受
60 到 86400 秒)。超时默认是拒绝,绝不是放行:过期扫荡把
pending 置为 expired,同时把 effective decision 记为 denied,
decided_by 记为 system-timeout,回执里看得见。
- 批准放行、拒绝拦截:决定一旦写入就是终态,再次决定返回
already_decided,原决定绝不被覆盖。回执字段:decided_by、
decided_at、decision,批准与拒绝、过期都留痕。
- 决定是 CAS:只有 pending 行能被改状态,并发下两个决定只
有一个能赢,输的那个拿到 already_decided 而不是假成功。
诚实边界(本片做到哪、没做到哪):
- 本片交付的是队列服务本身:建表迁移、创建、查询、批准、
拒绝、过期扫荡、回执、服务端端点、控制台 UI、单测。
- 本片没有把实时 elicitation 接到队列上,host.py 完全没动。
原因不是协议不让挂起:JSON-RPC 请求本来就可以晚回包,
挂起在协议上是成立的。真正缺的是通道:host.py 跑在 worker
拉起的子进程里,手头没有云 DB 连接,也没有到控制面的
带权请求通道(worker.py 本片禁动,没法给它递 DB 路径或
token)。在没有通道的情况下往 _answer_server_request 里
加“等决定”只会把回合无限挂死,所以不做假拦截。
- 在通道落地之前,实时 elicitation 的行为与今天完全一致:
approval=auto 时 accept 空 content,其他一律 decline。
队列里的决定目前是记录加事后审计口径,供通道落地后的
hook 消费,绝不要对外宣称“实时请求已被事前拦截”。
- 通道落地时 host 侧要做的事只有两件,都已在本模块留好
映射函数:elicitation 到达时先建 pending 项再挂起等决定,
决定到了按 elicitation_response 回包;超时按 expired 的
denied 处理。通道本身(worker 递 DB 路径还是控制面 HTTP
带权轮询,生产是 Postgres 所以 DB 直连只够 SQLite 开发
环境用)是合流阶段的单独设计,不在本片里猜一个。
遗留的审批类(明确不塞进本片):
- item/commandExecution/requestApproval:命令审批。现 host
通用回包写的是 approve/deny,但新协议的决定词表是
accept/decline/acceptForSession 等,通用回包本身就是错的,
要先修词表再接队列;kind 里已预留 command,映射函数
command_response 已按新协议写好,但不接实时流。
- item/fileChange/requestApproval:文件改动审批。决定里有
cancel(拒绝并立即中断整轮),不是纯二值,接队列要先定
cancel 的轮次语义,暂不接。
- item/permissions/requestApproval:权限审批。回包不是二值,
而是一份 GrantedPermissionProfile,要现拼 profile,暂不接。
- item/tool/requestUserInput:这根本不是审批,是向用户要
输入内容(问题列表),blocking 与否另有语义,应另起输入
队列,暂不接。
- item/tool/call 与 account/chatgptAuthTokens/refresh:前者是
动态工具执行,后者是凭证刷新,都不是用户审批,排除。
本模块只依赖标准库,不 import cloud 其他模块,避免循环
import;服务端与未来 worker/hook 共用。占位符用 ?,建表与
列补齐写法与 peer_messages 同款(db 适配层负责翻译)。
"""
import json
import time
import uuid
KINDS = ("elicitation", "command", "file_change", "permissions",
"user_input")
DECISIONS = ("approved", "denied")
DEFAULT_TIMEOUT_SECONDS = 3600
MAX_SUMMARY_CHARS = 2000
MAX_DETAIL_CHARS = 20000
def row_get(row, name):
"""行取值容错:sqlite3.Row 缺列报 IndexError、dict 缺键报
KeyError、空行报 TypeError,一律回 None(与 peer_messages
的同名函数同款,模块独立所以这里自带一份)。"""
try:
return row[name]
except (KeyError, IndexError, TypeError):
return None
def ensure_approvals_table(conn):
"""幂等建审批队列表。调用方负责 commit(服务端入口在写
操作后统一 commit;本函数自己不 commit,避免半截事务)。"""
conn.execute(
"CREATE TABLE IF NOT EXISTS task_approvals("
"id TEXT PRIMARY KEY, "
"task_id TEXT, "
"project_id TEXT, "
"kind TEXT, "
"summary TEXT, "
"detail TEXT, "
"status TEXT, "
"requested_by TEXT, "
"decided_by TEXT, "
"decided_at DOUBLE PRECISION, "
"decision TEXT, "
"created_at DOUBLE PRECISION, "
"updated_at DOUBLE PRECISION, "
"expires_at DOUBLE PRECISION)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_approvals_task"
" ON task_approvals(task_id, status)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_approvals_project"
" ON task_approvals(project_id, created_at)")
def _detail_to_str(detail):
if detail is None:
return None
if isinstance(detail, str):
text = detail
else:
try:
text = json.dumps(detail, ensure_ascii=False)
except (TypeError, ValueError):
text = str(detail)
if len(text) > MAX_DETAIL_CHARS:
raise ValueError("detail too long")
return text
def _detail_from_str(text):
if text is None:
return None
try:
return json.loads(text)
except (TypeError, ValueError):
return text
def approval_to_dict(row):
"""行转 dict:回执字段原样带出,detail 尽量还原成对象。"""
if row is None:
return None
return {
"id": row_get(row, "id"),
"task_id": row_get(row, "task_id"),
"project_id": row_get(row, "project_id"),
"kind": row_get(row, "kind"),
"summary": row_get(row, "summary"),
"detail": _detail_from_str(row_get(row, "detail")),
"status": row_get(row, "status"),
"requested_by": row_get(row, "requested_by"),
"decided_by": row_get(row, "decided_by"),
"decided_at": row_get(row, "decided_at"),
"decision": row_get(row, "decision"),
"created_at": row_get(row, "created_at"),
"updated_at": row_get(row, "updated_at"),
"expires_at": row_get(row, "expires_at"),
}
def create_approval(conn, task_id, project_id, kind="elicitation",
summary="", detail=None, requested_by=None,
timeout_seconds=DEFAULT_TIMEOUT_SECONDS, now=None):
"""创建一条待审批项,返回 dict。非法输入抛 ValueError,
调用方据此回 400;成功时 commit。
timeout_seconds:None 用默认值;0 表示不过期(只允许
模块级调用,服务端入口另有限制);负数非法。"""
if kind not in KINDS:
raise ValueError("kind must be one of: " + ", ".join(KINDS))
if not isinstance(summary, str) or not summary.strip():
raise ValueError("summary required")
summary = summary.strip()
if len(summary) > MAX_SUMMARY_CHARS:
raise ValueError("summary too long")
if timeout_seconds is None:
timeout_seconds = DEFAULT_TIMEOUT_SECONDS
if not isinstance(timeout_seconds, (int, float)) \
or isinstance(timeout_seconds, bool) \
or timeout_seconds < 0:
raise ValueError("timeout_seconds invalid")
detail_str = _detail_to_str(detail)
now = time.time() if now is None else now
expires_at = None
if timeout_seconds:
expires_at = now + timeout_seconds
item_id = uuid.uuid4().hex
ensure_approvals_table(conn)
conn.execute(
"INSERT INTO task_approvals(id, task_id, project_id,"
" kind, summary, detail, status, requested_by,"
" decided_by, decided_at, decision,"
" created_at, updated_at, expires_at)"
" VALUES(?, ?, ?, ?, ?, ?, 'pending', ?,"
" NULL, NULL, NULL, ?, ?, ?)",
(item_id, task_id, project_id, kind, summary, detail_str,
requested_by, now, now, expires_at),
)
conn.commit()
return approval_to_dict(conn.execute(
"SELECT * FROM task_approvals WHERE id = ?",
(item_id,)).fetchone())
def get_approval(conn, approval_id):
"""取一条,缺失回 None(不抛,让调用方定 404 还是 409)。"""
ensure_approvals_table(conn)
return approval_to_dict(conn.execute(
"SELECT * FROM task_approvals WHERE id = ?",
(approval_id,)).fetchone())
def list_approvals(conn, task_id=None, project_id=None, status=None):
"""列审批项:纯读,不扫荡过期(扫荡是写操作,调用方在
需要时先调 expire_overdue)。过滤全是可选的精确匹配。"""
ensure_approvals_table(conn)
query = "SELECT * FROM task_approvals WHERE 1 = 1"
args = []
if task_id is not None:
query += " AND task_id = ?"
args.append(task_id)
if project_id is not None:
query += " AND project_id = ?"
args.append(project_id)
if status is not None:
query += " AND status = ?"
args.append(status)
query += " ORDER BY created_at, id"
rows = conn.execute(query, args).fetchall()
return [approval_to_dict(r) for r in rows]
def decide_approval(conn, approval_id, decision, decided_by):
"""写决定。返回 (status, item):decided 表示本次写赢;
already_decided 表示该项已有终态(批准、拒绝或过期),
原决定绝不被覆盖;not_found 表示无此项。
决定是 CAS:UPDATE 只改 status='pending' 的行,rowcount
为 0 说明并发下已被别人先决定,调用方按 already_decided
处理,不要按成功回。成功与过期扫荡都 commit。"""
if decision not in DECISIONS:
raise ValueError("decision must be approved or denied")
ensure_approvals_table(conn)
item = get_approval(conn, approval_id)
if item is None:
return "not_found", None
if item["status"] != "pending":
return "already_decided", item
now = time.time()
cur = conn.execute(
"UPDATE task_approvals SET status = ?, decision = ?,"
" decided_by = ?, decided_at = ?, updated_at = ?"
" WHERE id = ? AND status = 'pending'",
(decision, decision, decided_by, now, now, approval_id))
conn.commit()
if cur.rowcount == 1:
return "decided", get_approval(conn, approval_id)
return "already_decided", get_approval(conn, approval_id)
def expire_overdue(conn, now=None):
"""过期扫荡:把已到 expires_at 的 pending 项置为 expired,
同时把 effective decision 记为 denied(超时默认拒绝,绝不
放行),decided_by 记为 system-timeout。返回过期条数。"""
ensure_approvals_table(conn)
now = time.time() if now is None else now
cur = conn.execute(
"UPDATE task_approvals SET status = 'expired',"
" decision = 'denied', decided_by = 'system-timeout',"
" decided_at = ?, updated_at = ?"
" WHERE status = 'pending' AND expires_at IS NOT NULL"
" AND expires_at <= ?",
(now, now, now))
conn.commit()
return cur.rowcount
def elicitation_response(decision):
"""未来 host hook 的回包映射(本片未接实时流,只定映射):
批准 = accept 空 content,与今天 approval=auto 的行为一致;
拒绝或过期 = decline。decision 取 approved/denied/expired
之外的任何值都按拒绝处理,绝不默认放行。"""
if decision == "approved":
return {"action": "accept", "content": {}}
return {"action": "decline"}
def command_response(decision):
"""未来命令审批 hook 的回包映射,按新协议词表写(accept
/ decline),不是 host.py 现通用回包的 approve/deny——后者
与新协议不符,见模块 docstring 的遗留清单。非批准一律
decline,绝不默认放行。"""
if decision == "approved":
return {"decision": "accept"}
return {"decision": "decline"}