Files
codex-app-server/cloud/approvals.py
T

296 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""运行时审批队列(细粒度审批,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:前者是
动态工具执行,后者是凭证刷新,都不是用户审批,排除。
本模块只依赖标准库与同为标准库叶子的 cloud.timeouts,
不 import 其他 cloud 模块,避免循环 import;服务端与未来
worker/hook 共用。占位符用 ?,建表与列补齐写法与
peer_messages 同款(db 适配层负责翻译)。
"""
import json
import time
import uuid
from cloud.peer_messages import row_get
from cloud.timeouts import APPROVAL_DEFAULT_SECONDS as _APPROVAL_DEFAULT
from cloud.timeouts import expires_at_for as _expires_at_for
from cloud.timeouts import resolve_module_timeout as _resolve_timeout
KINDS = ("elicitation", "command", "file_change", "permissions",
"user_input")
DECISIONS = ("approved", "denied")
DEFAULT_TIMEOUT_SECONDS = _APPROVAL_DEFAULT
MAX_SUMMARY_CHARS = 2000
MAX_DETAIL_CHARS = 20000
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=None, now=None):
"""创建一条待审批项,返回 dict。非法输入抛 ValueError,
调用方据此回 400;成功时 commit。
timeout_seconds:None 用默认值(cloud/timeouts.py 的
环境变量感知默认值,未设环境变量时为 3600;签名默认
故意写 None 而不是 3600,避免默认值在 import 时绑定死、
环境覆盖层传不进去);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")
# 不传 default:None 时走 timeouts 的环境变量感知默认值。
timeout_seconds = _resolve_timeout(timeout_seconds)
detail_str = _detail_to_str(detail)
now = time.time() if now is None else now
expires_at = _expires_at_for(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"}