feat: P1 补齐(自动评审队列、自动化+分诊、并发 caps、运行时审批队列)
This commit is contained in:
1 parent
29efc6c61f
commit
e0cecd70aa
18 files changed
+3474
-27
No files matched your search
@@ -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"}
|
||||
Reference in new issue
Block a user