入口组合:Fig S5。
第 7 章 · §7.5 Web 传输层¶
7.5 Web 传输层(miniharness/web/)¶
flowchart LR
subgraph lanes [Wire三泳道]
Unary["HTTP unary POST"]
Mux["WS remote.mux"]
Events["events plus events/result"]
end
Unary --> ApiPy["web/api.py"]
Mux --> MuxPy["web/mux.py streams.py"]
Events --> EvPy["web/events.py approvals.py"]
(详图与测试对照见 wire-tour.md。)
flowchart LR
UI["webui"]
Unary["POST /api/<endpoint><br/>两信封 RPC"]
Mux["WS /api/remote.mux<br/>Remote 流"]
Ev["$events / $events/result"]
Core["AgentLoop + Session"]
UI --> Unary
UI --> Mux
Mux --> Ev
Unary --> Core
Mux --> Core
Wire 约定
本章按实现顺序讲 web 传输层。端点、帧形状与错误码以 interface-wire.md 为准(当前 unary 101 条,列表见 miniharness/web/api.py 的 WebApi.ROUTES)。改 wire 时先改实现与该文件,再同步本章叙述。
对应 dsh 真实源码:
packages/client/connection(两信封 RPC + HTTP 载体)+packages/api/gateway(stream-protocol.ts+stream-server.ts:WS/api/remote.mux+$events注册表)+packages/api/session-controller(Typert Remotesession/*)+packages/api/remotes(Remote 事件瀑布 +$events/result)+packages/host/frontend-static(SPA 静态载体)。上游把 web 面收拢为「typert 一元 RPC + 单 WebSocket mux」,mini 跟随这一形态。前端(packages/bundle/web-app+packages/client)mini 以独立 React 工程webui/承载产品面、vanilla SPA 作教学参照(§7.5.4)。分层:
web/stream_protocol.py(§7.5.1)→web/api.py(§7.5.2)→web/uplink.py+web/mux.py+web/events.py+web/streams.py(§7.5.3)→web/server.py+web/attachments.py+web/downloads.py+web/launcher.py(§7.5.4)→web/approvals.py+web/frontend.py+web/static/(§7.5.4)。
7.5.1 信封:两信封 RPC + Remote 流 wire 语法(web/stream_protocol.py)¶
上游把通信收拢为单一两信封协议(同 packages/client/connection):
| 方向 | 形状 | 语义 |
|---|---|---|
| client → host | client-request(type/rpcId/method/payload) |
浏览器发起的调用 |
| host → client | server-response(type/rpcId/result/error) |
调用的回执 |
流式调用不走 SSE 专属宿主流,而是 WebSocket /api/remote.mux 上的一套Remote 流帧协议(stream_protocol.py,同 stream-protocol.ts):
- 客户端 → 服务端:
open(恰{streamId, endpoint, payload})、item({type, streamId}或加value)、end(恰{streamId})、cancel(恰{streamId})。每型字段集合精确匹配(同exactKeys),多一个键即拒并关 WS 1008——不投影丢弃未知键。 - 服务端 → 客户端:
item({type:'item', streamId, value},value 恒在——null 是合法 wire 值,JSON.stringify会丢 undefined,故 mini 显式补 null)、error({type:'error', streamId, error:{code, message, details}},error 帧即终态、不再补 end)、end({type:'end', streamId},纯终态帧;上游把{ok, error?}形状收敛为「独立 error 帧 + 纯 end」,失败路径不发 end)。 - 网关内部端点
$events(宿主→客户端事件线)与$events/result(客户端→宿主把事件传回宿主)——open_stream("$events")一旦打开即返回ready,宿主api-session/*事件线逐帧转发;$events/result是 unary 结算帧(parse_remote_event_result_payload),供 waterfall 审批等异步通道回投结果。
信封构造器(client_request/server_response/rpc_result_ok/error/rpc_error)同上游 packages/client/connection;传输层兜底错误投影 transport_error → {code:'gateway/cancelled'}(abort 语义)或 {code:'gateway/internal'}。互操作锚点:tests/test_web_stream_protocol.py 逐项断言 open/cancel/item/error/end 全形、多余键被拒与 $events/result payload 判定(含无损 JSON 判定)。
7.5.2 会话服务:unary 方法(web/api.py)¶
unary 端点全集与逐域说明以 interface-wire.md 为准;实现侧权威列表是
WebApi.ROUTES(miniharness/web/api.py),不要在本章硬编码个数。
WebApi(ctx, adapter, tools) 持有会话注册表(_agents:sessionId → AgentLoop,create 时 attach 一个常驻 AgentLoop)。会话操作全为 Typert Remote(packages/api/session-controller/src/index.ts):
| 方法 | 语义 |
|---|---|
session/list |
按 updatedAt 倒序 + blank / lastPromptAt 折叠投影(unary) |
session/search |
按关键字搜索(unary) |
session/create |
会话 id 缺省 session-<uuid4>;workspaceId → workspace/not-found;重复 id + 同 cwd 幂等返回、异 cwd → session/conflict;create 即 attach |
session/selectModel / session/modelCatalog / session/canOpenWorkspacePath / session/openWorkspacePath |
模型选择 / 目录打开(unary) |
session/rename / session/fork / session/cancel / session/updateQueue |
会话维护(unary;cancel 保留 inbox + FIFO 恢复,_parked 驻留) |
session/prompt |
mode ∈ {queue, steer};time zone 校验;/ 开头单文本块 → 命令注册表;需 requestId(缺 → gateway/bad-request) |
session/attachment |
附件投递(unary) |
session/page |
取代旧的 session/history:throughSeq/beforeSeq/maxMessages 游标分页 |
session/follow / session/control |
流式(见 §7.5.3) |
api.dispatch handlers 收裸 args(如 {cwd:...});{args:{...}} 包装与严格校验在 web/server.py::_unwrap_args 统一做(见 §7.5.4)。
7.5.3 流式:mux 单路径 + 有界上行 inbox + $events 注册表 + follow/control(web/mux.py + web/uplink.py + web/events.py + web/streams.py)¶
web/mux.py(RemoteStreamMuxConnection):单条/api/remote.muxWebSocket 承载全部 Remote 流。客户端open帧带 endpoint,run循环泵帧、_drive逐 open handler 协程、EOF 后发end结算、cancel中断流。全部 Remote 流收敛到这一条 WS 路径,没有/api/events.mux、/api/events.host这类 SSE 线。web/uplink.py(UplinkInbox+UplinkItems):每条 open 建一条有界单消费者上行 inbox(同stream-server.tsUplinkInbox),按整帧 UTF-8 字节记账,上限 262144 字节(create_app(stream_inbox_bytes=...)可调,同streamInboxBytes)。end是半关(缓冲里的item仍读得尽),end后的item→ 该流以gateway/protocol中止,缓冲超限 →gateway/uplink-overflow(details:{endpoint});违例只杀本流并以终态error帧落地,socket 不关。fail先到先得(流提前结束遮蔽缓冲),release后一律丢帧。宿主已结束的流 id 收到的item/end/cancel直接丢弃(上游 muxpush对 closed 流早退),只有重复open关 socket。 方法侧是UplinkItems(同GatewayInvocation.uplink的UplinkDecoder,index.ts):每项先过该 endpoint 声明的 codec,未声明则走无损 JSON 校验(NaN/Infinity、-0、循环、Date、函数、symbol、不可枚举属性等拒),解码结果再校验一次;被拒项以gateway/input-invalid(details:{endpoint, field:'uplink'})中止整条逻辑流。每次调用只能取一次迭代器;方法停止读时close丢弃未读项。web/events.py(EventStreamRegistry):$events注册表——ready首帧 +api-session/*事件线转发 +$events/result结算对拍。跨堆线程安全唤醒:TestClient/uvicorn 把 app 跑在 portal 线程,主线程ctx.emit广播不能直接调asyncio.Event.set,_ClientQueue._wake捕获运行 loop 用loop.call_soon_threadsafe(waiter)(含loop.is_closed守卫)。web/streams.py(GatewayStreams):open_stream(endpoint, payload, uplink=..., signal=...)按 endpoint 分发,并负责释放上行——$events这类网关自有流 open 即释放(其item帧被丢弃而不缓冲),其余流在整个流结束(含失败/取消/断连)时释放。每个流方法收到一个StreamInvocation(同ctx.invocation):uplink取本调用的上行项迭代器(一次),close随下行结束释放;取消句柄在invocation.signal。endpoint 声明了 codec 时走GatewayStreams.uplink_codecs(出厂空表——全部 Remote 方法In皆为never),未声明则走无损 JSON 校验:$events:open 即ready,随后事件帧转发。session/follow:首帧 snapshot{header, cursor, records, hasMore, projections},之后逐 event 帧(snapshot 后重投 cursor+1..end,同history.ts)。lazy async 生成器错误时机——体部RemoteStreamError(session/not-found/gateway/arguments-invalid)在首个await gen.__anext__处抛、非调用时,测试须迭代驱动。session/control:首帧 baseline{projections}(上游已去 queues/jobs),之后 projection 替换帧(同control.ts)。terminal/retain:一帧{type:'retained'}后无帧保持到取消或该身份被关闭(不激活 Agent;mini 无空闲回收调度器,持有不改变清理时机)。逐帧契约见docs/interface-wire.md§4.5。terminal/attach:首帧snapshot(整屏文本 +info+writable+ 可选inputWindow),随后有序output/state/ack帧(双工:上行TerminalInput的 write/resize 回 ack;read-only/not-running折带refusal的 ack);进程终态state帧投完后流才end;超maxBufferedBytes的慢消费者以gateway/internal中止,重连从新snapshot恢复(逐帧契约见docs/interface-wire.md§4.5)。workspace/follow:首帧完整 baseline(items/archivedSessionIds/pinnedSessionIds),随后有序 upsert/remove/order/archived/pinned 增量(重连重发 baseline;逐帧契约见docs/interface-wire.md§4.6)。workspaceFiles/changes:见docs/interface-wire.md§4(ready + change 帧)。- 未知 endpoint → 抛
RemoteStreamError(gateway/invocation-unavailable)。
会话队列数据:session/control 的 queue 帧(placement 三态快照)已在上游退役;队列改经 inbox 投影单元暴露——agent/inbox/spliced 事件 fold 出 {next-turn,
next-step} 双队列(core/agent_loop/projections.py inbox_projection),经 control 的
projection 替换帧(web/api.py)或 follow snapshot 的 projections 块广播给客户端。
7.5.4 HTTP/WS 载体 + 审批桥 + 静态服务 + 浏览器前端(web/server.py + web/attachments.py + web/downloads.py + web/launcher.py + web/approvals.py + web/frontend.py + web/static/)¶
create_app(api, api.gateway) 是一个 FastAPI 应用,路径判定镜像 handler.ts + stream-server.ts:
POST /api/<endpoint>→ unary RPC:payload 恰为{args}单层 plain object(严格解包,多余键/缺/前缀拒绝;非法集{}/{"args":{},"x":1}/{"args":None}/{"args":[]}/{"key":"val"}/{"args":""}都不放行);CHANNEL_PATTERN=/^\/[A-Za-z0-9._~-]+$/、ENDPOINT_SEGMENT_PATTERN=/^[A-Za-z0-9_$.-]+$/;content-type非application/json→ 415(跨站写围栏);body 非 JSON → 400。派发崩溃 → 500 纯文本;业务错误恒 200 +server-response(result.ok=false)。session.*的$events/result特判返回完整server-response信封(内层rpc_result_ok/error,rpcId 取 body 或哨兵invalid-request)。结果值含bytes时(workspaceFiles/readBytes的data)改用multipart/form-data(见下)。WS /api/remote.mux→RemoteStreamMuxConnection(§7.5.3)。GET /api/session.export?sessionId=<id>→ 会话导出下载(web/downloads.py):root + 子代理后代 + 被引用媒体打包 zip,200/400/404/501/500 状态码链,错误走私有信封外壳。- 非
/api/路径 → SPA 静态服务(web/frontend.py,frontend-static 约定):只服务 dist 根内文件;..上跳 → 403;未命中 →index.html200;MIME 按扩展名。dist 根默认web/static/(教学 vanilla,旧 wire 不对新后端工作),经MINIHARNESS_WEBUI_DIST可指向产品化前端构建产物(webui/dist/),约定不变。
web/launcher.py 把 WebApi + GatewayStreams + create_app 组装成可监听应用;host/port 优先级:cli/main 透传的 --host/--port > MINIHARNESS_WEB_HOST/PORT env > 缺省(上游是组合配置节,简化标注)。host 须为具体地址:通配(0.0.0.0/::)由 web/host.py 加载期拒绝,非回环具体地址须配 token。
审批桥(web/approvals.py,同 packages/api/remotes waterfall + interaction/user-approval):桥挂 async tools/ask 闸门(power check:_arm_ask 注册 tools/pre-execute 返回 {"kind":"ask"})→ 落 approval/asked 审计 → events.invoke('approval/request', {approval}) 以 $events waterfall 投递给所有客户端 → 首个 $events/result 经 receive_result 结算 → 落 approval/decided → 返回 bool 供管线放行/拒绝。outcome 映射:result∈APPROVAL_OUTCOMES(allowed-once|rejected|cancelled|unavailable,否则 unavailable fail-closed)/rejected→unavailable/next→await nxt/cancelled→cancelled;dispose 全 pending 'cancelled'(不悬挂)。
二进制附件(web/attachments.py,同 gateway encodeRpcResult + Connection fullResponse):JSON 装不下 bytes,故运行期把结果里的字节叶子投影为 null 占位,并在响应顶层登记附件描述 {path, codec:'bytes', part}(path 段是对象键或数组下标,按发现顺序编号)。result_response 分帧:失败与纯 JSON 结果仍是 200 + application/json;有附件则出 multipart/form-data——metadata 文本 part 装 {type, rpcId, result, attachments},第 i 个二进制 part 名 bytes-<i>,流式出体(不整份复制)。结果值出现循环引用 → 200 + gateway/internal(gateway: circular RPC result)。客户端一侧在 webui/src/wire/rpc.ts 的 parseBinaryResponse:content-type 是 multipart/form-data 就重组——按 path 把 part 写回 Uint8Array,part 名重复 / 非 bytes codec / 路径走不通 / 槽位不是 null 占位 / 有 part 未被声明,一律 TypeError。这样 workspaceFiles/readBytes 在 wire 上是原生字节,与上游 WorkspaceFileBytes.data: Uint8Array 同型(session/attachment 的 data 例外,仍是 base64 字符串,同上游)。
浏览器前端:两个形态,都只依赖本层 wire 约定。
产品化前端(webui/,仓库顶层独立 React+TS+Vite 工程):会话列表/新建(session/list/session/create)、Trajectory 折叠(选中会话 session/follow 拉 snapshot + 按 seq 去重增量)、审批面板($events waterfall → Allow once / Reject → $events/result 结算,outcome∈APPROVAL_OUTCOMES 之外 fail-closed)、队列/作业面板(队列 = session/control 投影帧折叠出的 inbox 投影,作业 = job/list roster 流)。开发期 Vite dev server 把 /api 与 /api/remote.mux 代理到本地 Python 后端(vite.config.ts);生产期 vite build → MINIHARNESS_WEBUI_DIST=webui/dist 让后端静态服务承载。src/wire/ 是纯 TS 约定客户端(无 UI 依赖,vitest 单测 mock fetch/WS),src/app/ 是 React 编排,src/ui/ 是无状态展示组件。
教学参照(web/static/):vanilla SPA(index.html + app.js + style.css,无构建步),消费的是上游重组前的旧 SSE wire(events.mux/respond/host.describe),对新后端不工作,仅作教学说明。
教学简化:心跳 = transport 级(ws_ping_interval=2 / ws_ping_timeout=4,同上游 gateway heartbeat:缺省 2s Ping + 连续 2 周期无 Pong terminate,web/launcher.py uvicorn_options);认证门 = 可选 token(配置 MINIHARNESS_WEB_TOKEN 后 /api/* 全域强制、WS 升级拒绝写 HTTP 401 同上游 rejectRemoteStreamUpgrade,非回环具体地址无 token 启动即拒绝,通配地址一律拒绝;接口约定见 interface-wire §1.1);$events/follow/control 无 since 恢复游标(重连重拉全量);载荷 schema 校验在 WebApi 内做(上游先过 zod);session 日志事件是 mappingproxy/tuple 冻结形态(core/session/json.py deep_freeze),序列化前经 thaw 还原;前端产品化工程 webui/ 走新 wire 但不整体移植上游 packages/client 的 UI 模块——无 slot 组合;webui Trajectory 按上游概念提供 Overview 时间线、虚拟化窗口与全文搜索,since 游标则与后端 wire 一致(上游 wire 无该字段);web/static/ vanilla SPA 是旧 wire 教学参照。测试覆盖:tests/test_web_{stream_protocol,uplink,events,mux,streams,approvals,server,attachments,export,frontend,auth}.py(真实 uvicorn + httpx/websockets)+ webui/ 的 vitest(wire 层含 multipart 重组 + trajectory 模型/搜索/组件,pnpm test / pnpm typecheck / pnpm build)。
运行方式:
python -m miniharness.cli --profile web # 走启动器(缺 key → adapter 构造不抛,启动后 describe 可用)
python -m miniharness --profile web --host 192.168.1.5 --port 8000 # 显式监听具体地址(非回环需配 MINIHARNESS_WEB_TOKEN;通配地址拒绝)
MINIHARNESS_WEB_PORT=8000 python -m miniharness --profile web
依赖是可选 extra:pip install "miniharness[web]"(fastapi + uvicorn + websockets);测试 tests/test_web_server.py 用真实 uvicorn 线程 + httpx/websockets——WS mux 流式与跨线程唤醒依赖真实 transport,TestClient 的进程内缓冲无法覆盖。