303 lines
13 KiB
Python
303 lines
13 KiB
Python
"""运行时审批队列(细粒度审批,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"}
|