跳转到内容

03. 运行时拓扑与 RPC

Craft Agents 有三种主要运行形态:完整 Electron、本地/远程 headless server、薄客户端 Electron。它们没有各自实现会话逻辑,而是复用相同 server-core,并通过统一 RPC 协议改变部署位置。

flowchart LR
R["Renderer"] --> P["Preload API"]
P --> LC["Local RPC client"]
LC --> LS["Electron main 内的 local server"]
LS --> SM["SessionManager"]
SM --> A["Agent backend"]
SM --> BP["BrowserPaneManager"]

UI、后端、文件系统和浏览器都在同一台机器,但 renderer 仍走 RPC 风格接口,不直接拿 SessionManager 对象。这为远程模式保留同一调用形态。

flowchart LR
CLI["CLI"] -->|"ws/wss"| WS["WsRpcServer"]
WEB["WebUI"] -->|"HTTP 静态资源 + WS"| WS
WS --> SM["SessionManager"]
SM --> FS["远程文件系统"]
SM --> A["远程 Agent/工具"]

WebUI 静态资源和 WebSocket 可共用一个 HTTP(S) 端口。服务端必须有 token 或 cookie session 验证;对非本机明文 WS 有显式保护。

flowchart LR
R["Renderer"] --> P["Preload"]
P -->|"wss"| RS["Remote Server"]
RS --> SM["Remote SessionManager"]
SM -. "invoke browser capability" .-> P
P --> BPM["Local BrowserPaneManager"]

会话、模型、文件工具在远端,窗口与内置浏览器留在本机。这是双向 RPC,不是普通 REST 客户端。

bootstrapServer() 接收依赖注入选项,顺序很有意义:

1. 读取并校验 server token
2. 建 PlatformServices
3. 初始化 config 目录和全局 config
4. 获取单实例 lock
5. 创建 model refresh service 和 SessionManager
6. 创建 WsRpcServer 并 listen
7. 把 RPC server 绑定给 SessionManager
8. 创建 OAuthFlowStore 与 handler deps
9. 注册所有 RPC handlers
10. 设置 SessionManager event sink = wsServer.push
11. 初始化已有 sessions
12. 启动 model refresh

为什么先 listen() 再初始化 session?启动期间服务端已经有 transport,但 handlers/event sink 会在 session 初始化前完成,避免初始化产生事件时没有出口。另一方面真正可用性仍需由 init gate/health 表达,不能仅以端口监听判断所有数据已加载。

服务端要求 token,长度至少 16,拒绝单字符重复,并对低字符多样性警告。默认生成 24 随机字节,编码成 48 位 hex,即 192-bit 熵。

源码:headless-start.ts

~/.craft-agent/.server.lock(或 CRAFT_CONFIG_DIR 下)记录 pid + startedAt。判断逻辑不仅检查 PID 是否存活,还比较系统 boot time,防止重启后 PID 被别的进程复用。Docker 中 PID 1 跨容器生命周期复用也有特殊处理。

这不是分布式锁;它保护同一配置目录不被两个 server 同时写。并行开发需要不同 CRAFT_CONFIG_DIR

传入证书时同一个 WsRpcServer 切到 HTTPS/WSS。根 server 入口还会阻止“非 loopback host + 无 TLS”的默认暴露,除非显式允许不安全模式。

协议由 packages/shared/src/protocol 定义,传输由 server-core/src/transport 实现。逻辑消息包含:

  • handshake / handshake_ack;
  • request / response / error;
  • push event;
  • ack / replay sequence;
  • ping/pong heartbeat;
  • server → client invoke 与 invoke result。

传输层不理解“创建 session”的业务,只按 channel 找 handler。业务 DTO 留在 shared protocol,使 CLI、preload 与 server 编译时共享。

WsRpcServer 为握手完成的连接维护:

interface ClientConnection {
id: string
workspaceId: string | null
webContentsId: number | null
capabilities: Set<string>
missedPongs: number
eventBuffer: BufferedEvent[]
lastAckedSeq: number
lastSentSeq: number
}

源码:transport/server.ts

身份字段各有用途:

  • clientId:连接/重连、反向调用和资源清理;
  • workspaceId:只向同 workspace 客户端推送;
  • webContentsId:Electron 多窗口定位;
  • capabilities:声明本客户端能执行哪些宿主动作。

握手还交换 protocol/app version。版本信息用于兼容性提示,protocol version 用于拒绝不兼容帧。

push(channel, target, ...args) 支持按目标广播,而不是每个 handler 自己遍历 socket:

  • 所有客户端;
  • 指定 client;
  • 指定 workspace;
  • 其他协议定义的 target 组合。

服务端 SessionManager 的 event sink 被设成 wsServer.push.bind(wsServer)。因此核心只声明“向哪个目标发什么 session event”,不依赖 WebSocket 实现。

每个 client 有单调 seq 和 event ring buffer。断线后连接状态会在 TTL 内保留;重连携带已确认序号,服务端可重放未 ack 事件。buffer 同时有大小与时间上限,防止无限占内存。

重要语义:这解决的是短暂断线期间的增量事件,不替代 session 持久化。若断线太久或 buffer 丢失,客户端应重新拉权威 session/message snapshot。

sequenceDiagram
participant C as Client
participant S as WsRpcServer
participant SM as SessionManager
C->>S: handshake(lastAckedSeq=41)
S-->>C: handshake_ack(clientId)
SM->>S: push session_event
S-->>C: event(seq=42)
Note over C,S: 网络断开,seq 42 未 ack
SM->>S: push event(seq=43),进入断线 buffer
C->>S: reconnect(clientId, lastAckedSeq=41)
S-->>C: replay 42, 43
C->>S: ack(43)

服务器按固定间隔 ping。连续漏掉阈值 pong 后断开;断开回调做两类清理:

  • transport 自己保留有限重连状态;
  • 注入的 cleanupClientResources(clientId) 释放客户端资源;
  • SessionManager 收到 onClientDisconnected,丢弃 browser host pin。

最后一点避免 remote session 永远指向一台已经离线的 Electron。

invokeClient(clientId, channel, ...args) 先检查:

  1. client 是否在线;
  2. 握手时是否声明此 capability;
  3. pending invoke 是否在 timeout 内返回。

失败分别给出 CLIENT_DISCONNECTEDCAPABILITY_UNAVAILABLE 等可识别 code。调用结果通过 correlation id 匹配 pending promise。

这使服务端可以请求客户端执行:

  • 浏览器 pane 操作;
  • 打开本地 URL/路径;
  • 某些必须出现在客户端的 OAuth/系统 UI。

capability 不是安全授权本身。服务端仍要决定调用哪个 client、哪些操作允许远程发起;Electron dispatcher 也应验证参数。

3.10 Channel routing:本地与远程的分水岭

Section titled “3.10 Channel routing:本地与远程的分水岭”

protocol/routing.ts 维护两类显式表:

窗口管理、native dialog、shell/terminal 集成、主题、更新、browser pane 等。它们依赖当前机器或当前 WebContents。

sessions、tasks、files、LLM connections、sources、OAuth、projects、skills、automations、messaging、workspace 等。这些属于 workspace server 的事实源。

RoutedClient 根据 channel 选择 local 或 remote client。使用白名单而非默认猜测,能让新增 channel 在 code review 时暴露部署语义。

一个典型错误是把 files:read 一律当本地:在薄客户端里,聊天使用的是远程 workspace 文件,读取应在远程;而“在 Finder 中显示”必须本地且可能根本不适用于远程路径。协议需要分别建模,而不是用一个模糊 file API。

registerAllRpcHandlers() 聚合 sessions/tasks/files/sources/oauth/settings 等模块。单个 handler 应做:

  1. 解出 request context(client/workspace);
  2. 校验输入与 workspace ownership;
  3. 调用 SessionManager 或 shared domain service;
  4. 返回 DTO;
  5. 让领域层负责持久化与事件。

它不应把 renderer-specific 状态塞进后端,也不应绕过 SessionManager 直接改同一个 session 文件。

stop() 是幂等的,顺序如下:

  1. push server:shuttingDown,给客户端 2 秒 grace;
  2. stop model refresh;
  3. cleanup SessionManager(flush、destroy agents/资源);
  4. close WS;
  5. dispose OAuthFlowStore;
  6. release lock。

先通知再关 socket,让客户端停止自动重连并展示明确状态;先 cleanup session 再断 OAuth/锁,降低半写文件与悬挂子进程。

headless entry 可把 WebUI HTTP handler 注入 WsRpcServer 底层 HTTP(S) server:

GET /... → 静态资源 / OAuth callback / session auth
Upgrade: ws → RPC WebSocket

这样反向代理只需暴露一个端口,TLS、cookie 和 origin 策略也更集中。WebUI 可用 cookie session 作为 bearer token 的替代握手认证,但二者由同一 server options 显式配置。

场景 保护机制 恢复方式
弱 token 启动前拒绝 换随机 token
重复 server pid + boot-aware lock 关闭旧实例或换 config dir
客户端短断线 seq + replay buffer 原 client id 重连
客户端长断线 buffer TTL/size 到期 重新拉 session snapshot
capability host 离线 pin 清理 + error code 重新选择在线 host
server shutdown 先 push grace event 客户端停止重连/稍后连接
远程无 TLS 入口保护 WSS 或反代 TLS
handler 抛错 typed error envelope 客户端按 code 呈现/重试

如果要给自己的 Agent 产品增加远程模式,最值得照搬的不是 WebSocket 语法,而是这三层分离:

业务接口(typed channels/DTO)
路由策略(local-only / remote-eligible / capability)
传输可靠性(auth / heartbeat / seq / replay / shutdown)

业务代码不知道 socket,传输层不知道 session,部署差异由 routing/capability 表达。

同一 SessionManager 能服务多种客户端,是因为系统把后端变成一个可独立运行的应用服务,而不是 Electron main 中不可分割的一坨代码。RPC 还承担了第二层职责:用 capability 把本地 GUI 能力反向借给远程 Agent。

下一章进入领域模型和磁盘布局,解释 RPC 操作的那些 workspace/session/source/task 最终如何成为可搬运的文件。