Files
ThearchyHelios d18a496ef9 问数据整轮放到服务端跑(后端):一轮是一个后台任务,建图、运行、复核、按复核重来、落库都在服务端,页面断了也会跑完
为什么:一轮问数据以前是浏览器在推(store/chat.ts 的 plan / launch / settle):网络一抖建图那条流就断、建了一半的图丢掉;
发起运行、复核、重来、落库都要页面在,页面一关这一轮就停在 running,15 分钟后才被判成断了(总方案 §0 的调查结论)。

做了什么:
- 流程和评测共用一份:eval/answer.Driver 里的流程抽到 chat/drive.TurnDriver,差异写成子类给的几项(生成的请求、目录视图、
  改图、入口、发起参数、停在审批上怎么办、复核等多久)。评测的 Driver 成了它的子类,行为和测试一字不变(test_eval_* 照跑)。
- chat/job.py:问数据的一轮是 core/job_runtime 上的后台任务(不占数据源的队,并发归「交互」类,第 2 波再给上限和优先级)。
  停在审批上照旧等人(评测是取消);复核最多等 25 秒,晚到的那次用量照记(review.late);按复核自动重来一次;用户点的
  重试本轮、重新运行、放宽步数、跑一下、继续运行、接着收尾都是同一轮里的一次尝试,上一次的结局归档进 meta.attempts。
- 事件日志(新表 chat_turn_events,models_chat.py,不进运行的封存范围):每一步按 seq 落库再叫醒订阅方,订阅方按 seq
  回库里读——回放和实时是同一段代码;心跳一拍也回库里看一次,第 2 波多进程时别的进程写的最迟一拍送到。
- 接口 api/chat_turns.py:POST /api/chat/turns(202 带 turn_id)、GET /{id}、GET /{id}/stream?after=(SSE:先回放、再推
  实时、带心跳、结束补 stream.end)、POST /{id}/cancel。挂 chat_scope,每个接口按这一轮所属的对话核对归属;访问密钥调不了。
- conversation_turns 那一行由服务端写(chat/record.py:状态、运行 id、答案、复核、meta 各项和页面以前写的一一对应);失败
  只记原因(meta.cause),说法留在界面层按 lib/explain 讲。TurnOut 带这一轮的任务(job),列表判「还在跑」先看任务。
- 使用者的问数据运行规则(白名单、自己的记忆范围、遮罩、只读)抽到 chat/launch.user_chat_run,POST /api/runs 和服务端的
  一轮共用,说法一字不变。
- 服务重启:还在建图的标中断(可重试本轮);已发起运行的接着等那次运行、照常复核收尾;和调度循环同一个开关,拷来的数据
  目录启动时不替人收尾,页面打开对话时再请服务端接着收尾(mode=resume)。老轮次停在建图中的同样标中断。
- 删对话时先停掉它正在跑的几轮(取消各自的运行),任务和事件日志一并删。

测试:tests/test_chat_turns.py——一轮跑完不依赖页面、直接回答与反问、开流前被拒、实时流回放后接实时与按 seq 重连(补齐、
不重复)、思考合并、建图中与运行中停止、重启时建图中标中断与已发起运行的接着收尾、开关关着时等人来接、等审批后收尾、
按复核重来、用户重试归档、一段对话一次一轮、继续运行被拒照 /runs 的说法、删对话停掉这一轮、使用者的归属与遮罩只读、
访问密钥调不了、驳回与失败与发起被拒的原因、晚到的复核、跑一下与重新运行不重建。去掉几处关键实现回跑,相应的测试都会失败。
2026-10-18 16:05:53 +08:00

378 lines
20 KiB
Python
Raw Permalink 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.
"""请求里的「人」:身份与权限的唯一入口(docs/design/app-platform-and-look.md §2.7、附录 C)。
登录方式三种:off(默认,今天的行为)、local、oidc。各接口不直接碰请求头或会话,只问这里:
- actor_of(header):记在各「人」列上的名字。关登录时照旧解码 X-Actor(自报署名);开了登录返回认证过的
用户名,请求头一律不看——这样各接口的签名一个都不用改;
- current_principal():完整身份(用户名、角色、组、是否认证过、登录方式、经什么认出来的),中间件按请求写进 contextvar。
访问密钥认出来的身份(via=key)一律按使用者算,另带这把密钥的 scope 和限定的应用(auth/keys.py);
- require_role(role):挂在 main.py 的 include_router(..., dependencies=[...]) 上的路由级依赖;
- can_read_run / can_read_artifact:运行、工件的读权限(user 角色只能读有授权的应用视图运行、自己参与的实例);
- ensure_role / ensure_can_publish:接口内部按请求内容判定的权限(MCP 的 stdio、受管级发布、版本退役,Q8);
- restricted():这次请求是不是开登录后的 user 角色(按请求内容收窄的接口用:问数据只能发起探索运行、工件按白名单给);
- run_events_readable(principal, run):运行的原始记录、事件、实时流能不能读(user 只读查询结果在工具边界遮过的问数据运行);
- ensure_conversation_access(session, conversation_id):接受 conversation_id 的接口核对对话归属(对话按人隔离);
- run_scope / evidence_scope / chat_scope:main.py 挂在运行、证据、问数据几组路由上的依赖,按 run_id 判断能不能读。
run_scope 同样管运行的 WebSocket,握手时按运行判;
- private_run_scope:挂在只给搭建者的运行子路由(对比、可验证报告、质询、假设分析)上,别人的个人应用刷新、个人蓝图
试运行只有拥有者能碰(auth/access.py 的 private_owner),管理员、搭建者也不例外。
关登录时:所有人等同 admin、全部放行,行为和加这一层之前一模一样(界面上挂横幅说明)。
开登录(local / oidc)的实现由 app/auth/ 提供:会话、中间件、OIDC、引导第一个管理员。这个模块只定接口。
"""
from __future__ import annotations
from contextvars import ContextVar
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import unquote
from fastapi import Depends
from starlette.requests import HTTPConnection
from app.api.coded import CHAT_FORBIDDEN, ROLE_REQUIRED, RUN_FORBIDDEN, RUN_PRIVATE, CodedHTTPException
#: 登录方式
AUTH_MODES = ("off", "local", "oidc")
#: 角色由高到低
ROLE_RANK = {"user": 1, "builder": 2, "admin": 3}
@dataclass(frozen=True)
class Principal:
"""这次请求是谁。
username:开登录时是认证过的用户名(不可改);关登录时是自报的署名,可能为空。
verified:是不是认证过的身份。关登录时恒为 False——审计、派给人的记录、run.origin 照实记。
"""
username: str | None
role: str = "admin"
display_name: str | None = None
user_id: str | None = None
groups: tuple[str, ...] = field(default_factory=tuple)
verified: bool = False
mode: str = "off"
#: 经什么认出来的(docs/design/next-round/04-对外集成与触达.md A2):off 关登录的自报署名;session 会话 cookie(开登录时
#: 匿名也是这一种:没认出会话);key 访问密钥;hook 入站钩子的签名;share 分享链接(第 3 波)
via: str = "session"
#: 按访问密钥认出来的(via=key):哪一把、叫什么、带哪些 scope、限定在哪些应用(None 是不限应用)。别的认法都是空的。
#: 权限封顶在使用者(A3,auth/keys.principal_for_key):role 一律是 user,这几项只在门禁(auth/keys.key_gate)里再收窄
key_id: str | None = None
key_name: str | None = None
scopes: frozenset[str] = field(default_factory=frozenset)
app_ids: tuple[str, ...] | None = None
def at_least(self, role: str) -> bool:
return ROLE_RANK.get(self.role, 0) >= ROLE_RANK.get(role, 99)
#: 中间件按请求写入(app/auth/)。没写(关登录、测试直接调函数)时为 None
_principal: ContextVar[Principal | None] = ContextVar("agentlab_principal", default=None)
def set_principal(principal: Principal | None) -> Any:
"""中间件用:写入这次请求的身份,返回 token 供 reset。"""
return _principal.set(principal)
def reset_principal(token: Any) -> None:
_principal.reset(token)
def auth_mode() -> str:
"""当前的登录方式。环境变量 AGENTLAB_AUTH_MODE 优先于设置页(app/auth/ 实现读取与缓存)。"""
from app.auth import current_mode
return current_mode()
def decode_actor(header: str | None) -> str | None:
"""请求头 X-Actor 里的署名(设置页填的那个),没填就是 None。
浏览器的请求头只能是 Latin-1:中文署名原样放进去,fetch 直接抛错、整个请求
发不出去。所以前端按 encodeURIComponent 编码后发,这里解回来;老客户端发的
纯 ASCII 署名解码前后一样。
"""
value = unquote((header or "").strip()).strip()
return value[:100] or None
def actor_of(header: str | None) -> str | None:
"""记在各「人」列上的名字。开了登录:认证过的用户名,请求头不看;关登录:解码 X-Actor。"""
principal = _principal.get()
if principal is not None and principal.mode != "off":
return principal.username
return decode_actor(header)
def signer_of(given: Any, header: str | None, limit: int = 100) -> str | None:
"""请求体里自带署名(signed_by)的留痕记谁:开了登录一律是认证过的用户名,请求体里填的不看——不然随手填一个名字就
盖掉了登录的人;关登录时请求体里填的优先(截到 limit 个字),其次 X-Actor,都只原样记下(未认证)。"""
principal = _principal.get()
if principal is not None and principal.mode != "off":
return principal.username
if isinstance(given, str) and given.strip():
return given.strip()[:limit]
return decode_actor(header)
def signer_verified() -> bool:
"""signer_of 记下的署名是不是认证过的身份:开了登录、这次请求的身份认证过时为真;关登录时署名是自报的,为假。
导入清单、累积快照清单、作废记录、清除原件记录的「是否认证」记这个(docs/design/next-round/05-已知遗留.md (c)),
和审计表、待办派单、run.origin 的 verified 同一个口径。证据要在生成那一刻定下:调用方和 signed_by 一起往下传。"""
principal = _principal.get()
return principal is not None and principal.mode != "off" and bool(principal.verified)
def current_principal() -> Principal:
"""这次请求的完整身份。关登录时:等同 admin、未认证,username 是中间件按 X-Actor 解出的自报署名;中间件没写
(不在请求里,比如测试直接调函数、启动时的整理、调度循环)时同样等同 admin、未认证,username 为空。"""
principal = _principal.get()
if principal is not None:
return principal
return Principal(username=None, role="admin", verified=False, mode="off", via="off")
def actor_verified() -> bool:
return current_principal().verified
def require_role(role: str) -> Any:
"""路由级依赖:至少是 role。关登录时放行。开登录时:没登录 401,角色不够 403(app/auth/ 实现)。"""
from app.auth import role_dependency
return role_dependency(role)
async def can_read_run(principal: Principal, run: Any) -> bool:
"""能不能读这次运行(证据、事件、工件)。关登录或 builder 以上:能。user:有授权的应用视图运行、自己参与的实例。"""
from app.auth import run_readable
return await run_readable(principal, run)
async def can_read_artifact(principal: Principal, artifact_id: str) -> bool:
"""能不能读这件工件:它属于的某次运行能读就能读。"""
from app.auth import artifact_readable
return await artifact_readable(principal, artifact_id)
def ensure_role(role: str) -> None:
"""接口内部用:至少是 role,否则 401(没登录,auth_required)/ 403(角色不够,role_required)。关登录时放行。
用在要看请求内容才知道要什么角色的地方(比如 MCP 的 stdio 只有管理员能建)。"""
from app.auth import require
require(role)
def ensure_can_publish(level: str | None) -> None:
"""发布工作流(Q8):受管级只给管理员,已发布级给搭建者及以上。版本退役直接 ensure_role("admin")。
BE-3 在 api/workflows.py 的发布、退役接口开头调。"""
ensure_role("admin" if level == "governed" else "builder")
def user_can_chat() -> bool:
"""开登录后 user 角色能不能用问数据(Q2,管理员在「设置 → 身份」里开)。"""
from app.auth import user_can_chat as allowed
return allowed()
async def ensure_conversation_access(session: Any, conversation_id: str | None) -> Any:
"""接受 conversation_id 的接口(助手的 generate-stream、generate、app-stream、from-run……)在读这段对话的历史、记来源
之前调:这段对话这个人能不能用(对话按人隔离,D-d)。能用交回对话(Conversation);没给 id、对话不存在交回 None
(和以前一样当没有历史,不报错);存在但不能用回 403。规则见 api/conversations.py 的同名函数。"""
from app.api.conversations import ensure_conversation_access as check
return await check(session, conversation_id)
def _forbidden() -> CodedHTTPException:
return CodedHTTPException(403, "没有权限查看这次运行", RUN_FORBIDDEN)
#: user 角色在自己的问数据运行上能读的(GET /api/runs/{id} 之后的部分):运行记录、事件、运行时的图,加上实时流
#: (WebSocket /stream,握手时另判)。这几样就是问数据页要的;状态、工件列表、checkpoint 历史不给
_CHAT_RUN_READS = ("", "/events", "/graph")
_RUNS_FOR_BUILDERS = "没有权限:运行记录只对搭建者及以上的角色开放"
def masked_at_boundary(run: Any) -> bool:
"""这次运行的查询结果是不是发起时就定下在工具边界遮(tools/datasource.py 的 MASK_RESULTS_OPTION)。开登录后 user 角色
发起的问数据运行一定带它(api/runs.py 的 start_run),随运行记录沿用到续跑、审批恢复。"""
from app.tools.datasource import MASK_RESULTS_OPTION
options = getattr(run, "options", None)
return isinstance(options, dict) and bool(options.get(MASK_RESULTS_OPTION))
async def run_events_readable(principal: Principal, run: Any) -> bool:
"""能不能读这次运行的原始记录、事件和实时流(GET /api/runs/{id}、/events、/graph,WebSocket /stream)。
关登录:能。builder 及以上:这次运行它能读就能(can_read_run:别人的个人应用刷新、个人蓝图试运行读不了)。
user:问数据的运行(origin_kind=chat),这次它能读(can_read_run),而且查询结果在工具边界就遮过(masked_at_boundary)。
三个条件缺一不可:没在边界遮过的运行,事件里 tool.end 的预览、运行状态里是查询结果的原文,遮罩管不到——自己发起的
应用实例、有授权的应用视图运行经应用页和证据接口读(那两处按派生遮罩给)。服务端替他派生的运行也带着边界遮罩,但图
不是他的助手搭的:口径对账(ledger/reconcile)的图里原样是口径取数节点的 SQL 模板(口径的 SQL 可能写着遮罩值,所以
问数据里使用者不看口径),同样经证据接口读(展示的 SQL 里写着的遮罩值换成「已遮罩」,同应用视图运行)。实际上满足
三条的就是它自己开登录后发起的问数据运行;关登录时自报同名留下的老运行没在边界遮过,开了登录也读不到。"""
if principal.mode == "off":
return True
if principal.at_least("builder"):
return await can_read_run(principal, run)
if run is None or not principal.username or getattr(run, "origin_kind", None) != "chat" \
or not masked_at_boundary(run):
return False
return await can_read_run(principal, run)
def _own_chat_run(principal: Principal, run: Any) -> bool:
return run is not None and bool(principal.username) and run.started_by == principal.username \
and run.origin_kind == "chat"
async def _run_scope(conn: HTTPConnection) -> None:
"""/api/runs 下的接口和运行的实时流。builder 及以上放行,只有别人的个人应用刷新、个人蓝图试运行例外(只给拥有者,
_refuse_private);user 角色只在管理员开了问数据(Q2)时能碰这组接口:
- 列表、别人的运行一律不给(记录页是给搭建者看的);
- 能发起(只能是问数据的探索运行,start_run 里按请求体再查);能停止自己的问数据运行;
- 能读运行记录、事件、运行时的图,能连 WebSocket:按运行判(run_events_readable),握手时就判,不再一刀切只给
builder。能读的就是它自己发起、查询结果在工具边界遮过的问数据运行,事件、回答里只有遮过的值;
- 自己发起的应用实例、有授权的应用视图运行、参与的实例,经应用页(/api/apps/{id}/instances)和证据接口
(evidence_scope)读,这里不给原始事件和状态:tool.end 的预览、运行状态里是查询结果的原文,遮罩管不到。"""
principal = current_principal()
if principal.mode == "off":
return
if principal.at_least("builder"):
await _refuse_private(conn, principal)
return
if conn.scope["type"] == "websocket":
# 握手前拒:服务端回 1008,不建立连接。没登录的中间件已经挡掉了,这里只剩按运行判
if not (principal.username and user_can_chat()
and await run_events_readable(principal, await _load_run(conn.path_params.get("run_id")))):
from fastapi import WebSocketException, status
raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION)
return
ensure_role("user")
if not user_can_chat():
raise CodedHTTPException(403, _RUNS_FOR_BUILDERS, ROLE_REQUIRED)
method = conn.scope.get("method", "GET")
run_id = conn.path_params.get("run_id")
if run_id is None:
if method == "POST" and conn.url.path.rstrip("/") == "/api/runs":
return
raise CodedHTTPException(403, _RUNS_FOR_BUILDERS, ROLE_REQUIRED)
run = await _load_run(run_id)
rest = conn.url.path.rstrip("/").removeprefix(f"/api/runs/{run_id}")
if method == "GET" and rest in _CHAT_RUN_READS:
if await run_events_readable(principal, run):
return
raise _forbidden()
if not _own_chat_run(principal, run):
raise _forbidden()
if method == "POST" and rest == "/cancel":
return
raise CodedHTTPException(403, "没有权限:这项操作需要搭建者及以上的角色", ROLE_REQUIRED)
async def _load_run(run_id: str | None) -> Any:
if not run_id:
return None
from app.db.base import SessionLocal
from app.db.models import Run
async with SessionLocal() as session:
return await session.get(Run, run_id)
#: 搭建者、管理员碰别人的个人应用刷新、个人蓝图试运行时的说法
PRIVATE_RUN = "没有权限查看这次运行:个人应用的刷新和个人蓝图的试运行只有拥有者能看"
#: 运行子路由里另带的运行 id(对比的基准 ?base=)
_RUN_QUERY_KEYS = ("base",)
async def _refuse_private(conn: HTTPConnection, principal: Principal) -> None:
"""builder 及以上:路径里的运行(和对比的基准)是别人的个人应用刷新、个人蓝图试运行时拒(403,WebSocket 握手前
1008)。不存在的运行放过去,由接口照旧回 404。"""
ids = [conn.path_params.get("run_id"), *(conn.query_params.get(k) for k in _RUN_QUERY_KEYS)]
for run_id in (i for i in ids if i):
if await can_read_run(principal, run_id):
continue
if conn.scope["type"] == "websocket":
from fastapi import WebSocketException, status
raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION)
raise CodedHTTPException(403, PRIVATE_RUN, RUN_PRIVATE)
async def _private_run_scope(conn: HTTPConnection) -> None:
"""只给搭建者的运行子路由(main.py 里对比、可验证报告、质询、假设分析):角色由 require_role 判,这里只管别人的
个人应用刷新、个人蓝图试运行。"""
principal = current_principal()
if principal.mode != "off":
await _refuse_private(conn, principal)
def restricted() -> bool:
"""这次请求的人是不是开登录后的 user 角色:对它来说遮罩就是边界,只能做问数据、用应用这几件事。"""
principal = current_principal()
return principal.mode != "off" and not principal.at_least("builder")
async def _evidence_scope(conn: HTTPConnection) -> None:
"""/api/runs/{run_id}/evidence… 这组证据接口:builder 及以上放行,别人的个人应用刷新、个人蓝图试运行除外(只给
拥有者);user 角色只读(GET),而且这次运行要能读(can_read_run)。证据接口交出的查询行本身按派生遮罩处理过。"""
principal = current_principal()
if principal.mode == "off" or conn.scope["type"] != "http":
return
if principal.at_least("builder"):
await _refuse_private(conn, principal)
return
ensure_role("user")
if conn.scope.get("method", "GET") != "GET":
raise CodedHTTPException(403, "没有权限:这项操作需要搭建者及以上的角色", ROLE_REQUIRED)
run_id = conn.path_params.get("run_id")
if not run_id or not await can_read_run(principal, run_id):
raise _forbidden()
async def _chat_scope(conn: HTTPConnection) -> None:
"""问数据和助手(对话、生成):builder 及以上;user 角色要管理员开了问数据(Q2),而且只给问数据用得到的那几个接口。
对话按人隔离(只看得到、只改得动自己建的)在 api/conversations.py 里按对话判(ensure_conversation_access)。"""
principal = current_principal()
if principal.mode == "off" or principal.at_least("builder"):
return
ensure_role("user")
path = conn.url.path.rstrip("/")
method = conn.scope.get("method", "GET")
if user_can_chat() and (path.startswith(_CHAT_PREFIXES) or (method, path) in _CHAT_ROUTES):
return
raise CodedHTTPException(403, "没有权限:问数据和助手需要搭建者及以上的角色,或由管理员为使用者开启问数据",
CHAT_FORBIDDEN)
#: user 角色开了问数据时能用的整组接口:对话,和服务端跑的问数据一轮(发起、看、停,app/chat/job.py)。归属在接口里按对话判
_CHAT_PREFIXES = ("/api/conversations", "/api/chat/turns")
#: user 角色开了问数据时能用的其余接口:问数据的生成流、读当前助手模型、问数据页选数据源用的数据源名单
#: (GET /api/datasources/names,只有名字、说明和种类,不含连接信息)
_CHAT_ROUTES = frozenset({("POST", "/api/copilot/generate-stream"), ("GET", "/api/copilot/model"),
("GET", "/api/datasources/names")})
run_scope = Depends(_run_scope)
evidence_scope = Depends(_evidence_scope)
chat_scope = Depends(_chat_scope)
private_run_scope = Depends(_private_run_scope)
def role_by_method(*, read: str, write: str) -> Any:
"""读(GET、HEAD)要 read,改动要 write 的路由级依赖:模型接入、运行默认值这类搭建者只读的设置。"""
async def _check(conn: HTTPConnection) -> None:
ensure_role(read if conn.scope.get("method", "GET") in ("GET", "HEAD") else write)
return Depends(_check)