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